<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
  <title>Shivam Singh: Notes</title>
  <link>https://shivamsingh262.com/notes/</link>
  <atom:link href="https://shivamsingh262.com/feed.xml" rel="self" type="application/rss+xml"/>
  <description>Notes by Shivam Singh on AI agents, data infrastructure and developer tools.</description>
  <language>en</language>
  <item>
    <title>LLM output token limit: truncated JSON and a silent fallback</title>
    <link>https://shivamsingh262.com/notes/llm-output-token-cap/</link>
    <guid>https://shivamsingh262.com/notes/llm-output-token-cap/</guid>
    <pubDate>Wed, 21 Oct 2026 00:00:00 GMT</pubDate>
    <description>A 41-finding scan outgrew the model&apos;s output limit and quietly switched providers. Splitting the work fixed it, and splitting it too far broke something else.</description>
    <content:encoded><![CDATA[<p>Our AI marketing analyst has a &quot;health scan&quot;: it runs a set of checks across a
brand&#39;s ad and analytics accounts, ranks the findings, and then asks an LLM to
explain each finding and suggest what to do about it.</p>
<p>One day a scan took almost seven minutes. Nothing failed, nothing alerted, and
the report looked fine. That made it worth looking into.</p>
<h2 id="what-happened">What happened <a class="anchor" href="https://shivamsingh262.com/notes/llm-output-token-cap/#what-happened" aria-label="Link to this section">#</a></h2>
<p>The scan had raised 41 findings, and the code asked for every interpretation in
<strong>one</strong> reply, as a single JSON object.</p>
<p>The reply outgrew the call&#39;s <strong>8,192-token output cap</strong>. The model stopped
mid-object, the JSON didn&#39;t parse, and our LLM layer did what it was built to do
with an invalid reply: retry on the fallback provider. That happened twice.</p>
<p>The scan took <strong>407 seconds</strong>. Most of it went on generating long replies that
got cut off and then thrown away, and a different model than intended wrote the
final report.</p>
<p>All of this was silent:</p>
<ul>
<li>No exception reached the user, because the fallback worked.</li>
<li>The report looked normal, because the fallback model was good enough.</li>
<li>The only symptom was latency, and scans run in the background where nobody
watches latency.</li>
</ul>
<h2 id="why-it-slipped-past-testing">Why it slipped past testing <a class="anchor" href="https://shivamsingh262.com/notes/llm-output-token-cap/#why-it-slipped-past-testing" aria-label="Link to this section">#</a></h2>
<p>The output size grows with the number of findings. Test brands raised 6 to 12
findings, which fit easily. A real brand with a lot going wrong raised 41. The
prompt hadn&#39;t changed, but the output was several times bigger.</p>
<h2 id="fix-1-treat-stopped-at-the-cap-as-its-own-failure">Fix 1: treat &quot;stopped at the cap&quot; as its own failure <a class="anchor" href="https://shivamsingh262.com/notes/llm-output-token-cap/#fix-1-treat-stopped-at-the-cap-as-its-own-failure" aria-label="Link to this section">#</a></h2>
<p>A reply cut off by the output cap is not the same as malformed JSON. The API
tells you why it stopped (<code>stop_reason: &quot;max_tokens&quot;</code>), so:</p>
<ul>
<li>It&#39;s logged as <code>llm_output_truncated</code>, with its own count on the dashboard.</li>
<li><strong>Retrying the same request on the same provider is pointless</strong>, because it
will hit the same cap. For batched calls, a cut-off reply goes straight to the
fallback instead of a live retry that is bound to fail the same way.</li>
</ul>
<h2 id="fix-2-split-the-work-into-groups">Fix 2: split the work into groups <a class="anchor" href="https://shivamsingh262.com/notes/llm-output-token-cap/#fix-2-split-the-work-into-groups" aria-label="Link to this section">#</a></h2>
<p>Findings are now interpreted in <strong>groups of 12</strong>, all sent at once:</p>
<ul>
<li>Each group&#39;s reply stays well inside the cap, however many findings a scan
raises.</li>
<li>Each group gets its own <a href="https://shivamsingh262.com/notes/code-computes-llm-interprets/">number validation and retry</a>, so one bad group doesn&#39;t redo
the other three.</li>
<li>Every group uses the same skill set, so the system prompts are identical and
share the prompt cache.</li>
</ul>
<p>The same scan then ran with no truncation and no fallback in <strong>253 seconds
instead of 407</strong>.</p>
<h2 id="then-i-split-it-too-far">Then I split it too far <a class="anchor" href="https://shivamsingh262.com/notes/llm-output-token-cap/#then-i-split-it-too-far" aria-label="Link to this section">#</a></h2>
<p>Live chat turns had the same shape on a smaller scale: one call explaining six
findings took 34 to 36 seconds, the slowest step in a turn. The obvious next step
was one call per finding, all at once, so the step takes only as long as the
slowest finding.</p>
<p>It was faster, and the answers got worse. Explaining findings together had been
doing work I hadn&#39;t noticed:</p>
<ul>
<li>An action stopped naming the ad set that <em>another</em> finding said to scale.</li>
<li>A gap between two data sources stopped pointing at the tracking problem
another finding had already found.</li>
<li>Two findings gave contradicting causes for the same drop.</li>
<li>A finding recommended running an analysis that a sibling finding already was.</li>
</ul>
<p>I tried giving each call every finding as shared, cached context, with a short
part naming the one finding to explain. That brought back most of the links, but
it added prompt complexity and new edge cases (citing another finding&#39;s numbers,
for one). The same day, I reverted to groups.</p>
<h2 id="what-i-took-from-it">What I took from it <a class="anchor" href="https://shivamsingh262.com/notes/llm-output-token-cap/#what-i-took-from-it" aria-label="Link to this section">#</a></h2>
<ul>
<li><strong>Output size is a function of your input.</strong> If a reply lists one item per
input, its worst case is set by your largest customer, not your test fixtures.</li>
<li><strong>Check the stop reason before you parse.</strong> Cut-off JSON is a capacity
problem, not a formatting one, and needs a different response.</li>
<li><strong>Fallbacks hide problems.</strong> Record when a fallback fires, and alert on it.</li>
<li><strong>Parallel calls lose shared reasoning.</strong> Splitting a prompt splits what the
model can connect. Make each batch as small as the limit requires, and no
smaller.</li>
</ul>
<p><em>Related: <a href="https://shivamsingh262.com/notes/code-computes-llm-interprets/">Stopping an LLM from making up numbers</a>, on the validation each of these groups runs.</em></p>
]]></content:encoded>
  </item>
  <item>
    <title>How a falling-sand simulation works, with a playable demo</title>
    <link>https://shivamsingh262.com/notes/grainfall/</link>
    <guid>https://shivamsingh262.com/notes/grainfall/</guid>
    <pubDate>Wed, 21 Oct 2026 00:00:00 GMT</pubDate>
    <description>Grainfall is a small C++ falling-sand sandbox. Here&apos;s how sand, water, smoke and fire come out of a grid of cells and a few local rules, and you can play it right here.</description>
    <content:encoded><![CDATA[<p><a href="https://github.com/shivamsingh262/grainfall">Grainfall</a> is a falling-sand
sandbox I wrote in C++20 with <a href="https://www.raylib.com/">raylib</a>. You pour sand,
flood it with water, build stone walls and set wooden forests on fire. This page
runs the same code, compiled to WebAssembly:</p>
<iframe src="https://shivamsingh262.com/notes/grainfall/grainfall.html" title="Grainfall falling-sand sandbox" loading="lazy"></iframe><p><em>Click the game first so it receives key presses. Left mouse paints, right mouse
erases, <code>1</code>–<code>7</code> picks an element, the mouse wheel sets the brush size, <code>Space</code>
pauses and <code>C</code> clears. It&#39;s built for a mouse. <a href="https://shivamsingh262.com/notes/grainfall/grainfall.html">Open it full
screen</a> if the frame is too small.</em></p>
<p>Try this: paint a block of wood, then paint a little fire just under it.</p>
<p>None of what you see is scripted. There&#39;s no physics engine, no fluid solver
and no fire effect. Each cell follows a few rules about its immediate neighbors,
and the sloped dunes, levelling lakes and spreading fires all come from
those rules.</p>
<h2 id="the-world-is-a-grid-of-bytes">The world is a grid of bytes <a class="anchor" href="https://shivamsingh262.com/notes/grainfall/#the-world-is-a-grid-of-bytes" aria-label="Link to this section">#</a></h2>
<p>The world is a fixed 320×180 grid, scaled up 4× on screen. Each cell is five
bytes:</p>
<pre><code class="language-cpp">struct Cell {
    ElementId type;         // sand, water, stone, ...
    uint8_t shade;          // random color variation, rolled when the particle spawns
    uint8_t life;           // remaining frames, for fire and smoke
    uint8_t lastUpdate;     // frame stamp: already moved this frame?
    uint8_t lastDisplaced;  // frame stamp: already pushed aside this frame?
};
</code></pre>
<p>The grid holds 57,600 cells, about 280 KB. Every frame updates the whole grid in
place, cell by cell. There&#39;s no second buffer and nothing runs in parallel.</p>
<h2 id="one-pass-per-frame-bottom-to-top">One pass per frame, bottom to top <a class="anchor" href="https://shivamsingh262.com/notes/grainfall/#one-pass-per-frame-bottom-to-top" aria-label="Link to this section">#</a></h2>
<p>Each frame scans the rows from the <strong>bottom up</strong>. This matters for anything that
falls. If you scanned top-down, a grain moved from row 10 to row 11 would be
visited again on row 11 and fall a second time, then a third, reaching the floor
in one frame. Going bottom-up, the grain lands in a row that has already been
scanned.</p>
<p>Gases rise, though, so for them bottom-up is the wrong order. That&#39;s what
<code>lastUpdate</code> is for. Before a particle moves, it&#39;s stamped with the current
frame number, and the stamp moves with it. Any cell already stamped with this
frame is skipped. So every particle moves at most once per frame, whichever way
it&#39;s going.</p>
<p>Within a row, the scan direction is <strong>picked at random every frame</strong>. With a
fixed left-to-right scan, particles on the left would always move first and
take the free cells. Sand piles would lean and water would drift to one side.
A random direction removes that bias.</p>
<h2 id="four-movement-rules">Four movement rules <a class="anchor" href="https://shivamsingh262.com/notes/grainfall/#four-movement-rules" aria-label="Link to this section">#</a></h2>
<p>Every element belongs to one of four movement classes, and the simulation has
exactly one rule for each:</p>
<ul>
<li><strong>None</strong> (stone, wood): never moves.</li>
<li><strong>Powder</strong> (sand): move down if possible; otherwise try down-left or
down-right, in random order. That&#39;s all it takes to make sloped piles: a grain
landing on a peak rolls off to one side until it finds a gap, so piles settle
at a natural angle.</li>
<li><strong>Liquid</strong> (water): the powder rule, and if all three cells below are blocked,
flow sideways up to <code>dispersion</code> cells along the row (5 for water). This
sideways flow levels a pile of water into a flat surface. Without it, water
would just be wet sand.</li>
<li><strong>Gas</strong> (fire, smoke): the liquid rule flipped upside down, plus a random
wobble of one cell left or right on each step up. The wobble is why smoke
billows and flames flicker instead of rising in straight columns.</li>
</ul>
<h2 id="density-and-a-bug-that-teleported-water">Density, and a bug that teleported water <a class="anchor" href="https://shivamsingh262.com/notes/grainfall/#density-and-a-bug-that-teleported-water" aria-label="Link to this section">#</a></h2>
<p>A particle can move into an empty cell, or <strong>swap places with a lighter liquid
or gas</strong>. Each element has a density (sand 150, water 100, smoke 2, fire 1), so
sand sinks through water, and water and sand both fall through smoke. Solids
never get pushed, and gases only rise into empty space.</p>
<p>The simple version of this rule has a bug. Pour a steady stream of sand into a
lake and water gets pulled up through the stream to the top <strong>in a single
frame</strong>. The grain
at the bottom swapped with the water below it. Then the grain above it swapped
with that same water cell, now one row higher. Then the next grain did the same,
all the way up. The water never got updated; it was carried along by swaps.</p>
<p>The fix is the second frame stamp, <code>lastDisplaced</code>. A particle that gets pushed
aside is stamped, and a stamped particle can&#39;t be pushed again until the next
frame. Now the water moves up one cell per frame, as you&#39;d expect.</p>
<h2 id="fire-is-three-elements">Fire is three elements <a class="anchor" href="https://shivamsingh262.com/notes/grainfall/#fire-is-three-elements" aria-label="Link to this section">#</a></h2>
<p>Fire is the most interesting behavior here. It&#39;s built from three generic
properties that any element can use:</p>
<ul>
<li><strong>Lifetime.</strong> Fire and smoke are given a random lifespan when they spawn
(15–45 frames for fire). When it runs out, they turn into their decay product:
fire becomes smoke, and smoke becomes empty space.</li>
<li><strong>Burning.</strong> Each frame, a burning cell checks its 8 neighbors. Each flammable
neighbor has a small chance of catching fire (wood: 8 out of 256 per frame).
If any neighbor puts out fire (water does), the burning cell decays
immediately.</li>
<li><strong>Emission.</strong> Some cells randomly spawn another element in the cell just
above them.</li>
</ul>
<p>Wood doesn&#39;t turn into fire when it catches. It turns into <strong>ember</strong>: an
element that stays in place, sets its neighbors alight, gives off flames
upward, and after 60–160 frames turns into smoke. So a burning tree glows,
throws flames and collapses into smoke, and the fire moves slowly through the
wood. Wood&#39;s low flammability is what keeps the spread slow. Raise it and a
forest goes up in a few frames.</p>
<h2 id="elements-are-data">Elements are data <a class="anchor" href="https://shivamsingh262.com/notes/grainfall/#elements-are-data" aria-label="Link to this section">#</a></h2>
<p>Because those behaviors are generic, an element is a row in a table, not code:</p>
<pre><code class="language-cpp">{.name = &quot;Wood&quot;,
 .color = {115, 74, 42, 255},
 .colorJitter = 16,
 .density = 255,
 .flammability = 8,
 .burnsInto = ElementId::Ember},
</code></pre>
<p>To add oil, you add one enum value and one table row: a liquid with density 80
(so it floats on water) and some flammability. The palette, number keys and
simulation all pick it up automatically, and a <code>static_assert</code> fails the build
if the table and the enum get out of sync.</p>
<h2 id="drawing-it">Drawing it <a class="anchor" href="https://shivamsingh262.com/notes/grainfall/#drawing-it" aria-label="Link to this section">#</a></h2>
<p>Rendering is the cheapest part. Each frame fills a 320×180 pixel buffer with one
color per cell, uploads it as a single texture, and draws that texture scaled up
with nearest-neighbor filtering. That&#39;s one texture upload and one draw call.</p>
<p>The color comes from the element&#39;s base color, brightened or darkened by the
cell&#39;s <code>shade</code> byte. Shade is rolled once when a particle spawns, so a sand pile
looks grainy and the grains keep their colors as they slide. Fire and embers
instead get a new shade every frame, which makes them flicker without any
animation code.</p>
<h2 id="running-it-in-a-browser">Running it in a browser <a class="anchor" href="https://shivamsingh262.com/notes/grainfall/#running-it-in-a-browser" aria-label="Link to this section">#</a></h2>
<p>The web build is the same C++ code, compiled to WebAssembly with
<a href="https://emscripten.org/">Emscripten</a>. Only one change was needed: browsers
don&#39;t allow a blocking <code>while</code> loop, so the loop body moved into a function the
browser calls once per frame. The download is about 380 KB, and the code and
build instructions are on <a href="https://github.com/shivamsingh262/grainfall">GitHub</a>.</p>
]]></content:encoded>
  </item>
  <item>
    <title>Stopping an LLM from making up numbers in an AI analyst</title>
    <link>https://shivamsingh262.com/notes/code-computes-llm-interprets/</link>
    <guid>https://shivamsingh262.com/notes/code-computes-llm-interprets/</guid>
    <pubDate>Wed, 21 Oct 2026 00:00:00 GMT</pubDate>
    <description>How I stopped an LLM marketing analyst from writing numbers it didn&apos;t compute, and the five places they still slipped through.</description>
    <content:encoded><![CDATA[<p>I lead the build of an AI analyst for e-commerce marketing teams. You ask it
things like &quot;why did ROAS drop last week?&quot; and it pulls data from the ad
platforms, analytics and the store, runs checks, and writes an answer.</p>
<p>The worst thing it can do is write a wrong number confidently. A media buyer who
sees one wrong spend figure stops trusting every other figure on the screen. So
the system follows one rule: <strong>code computes every number, the LLM only
interprets.</strong></p>
<h2 id="numbers-are-facts-not-text">Numbers are facts, not text <a class="anchor" href="https://shivamsingh262.com/notes/code-computes-llm-interprets/#numbers-are-facts-not-text" aria-label="Link to this section">#</a></h2>
<p>Every number the analyst can show comes from code. Plain functions compute
spend, conversion rate, the gap between two sources and so on, and each one
produces a <em>fact</em>: a key, a value, a format (money, percent, count) and the step
and source it came from.</p>
<p>The LLM never sees a number it is allowed to type. It writes placeholders
instead:</p>
<pre><code class="language-text">Spend rose to {{fact:f_3a9c.spend}} while purchases fell to {{fact:f_3a9c.purchases}}.
</code></pre>
<p>Code swaps each placeholder for the fact&#39;s value, formatted in the brand&#39;s
reporting currency. So numbers always render the same way, the LLM can&#39;t mistype
them, and every value on screen links back to the step that produced it.</p>
<h2 id="validating-every-number">Validating every number <a class="anchor" href="https://shivamsingh262.com/notes/code-computes-llm-interprets/#validating-every-number" aria-label="Link to this section">#</a></h2>
<p>After each LLM call, a validator reads the text:</p>
<ul>
<li>A placeholder with an unknown fact key fails.</li>
<li><strong>Any literal digit left in the text fails.</strong> The only exceptions are a small
allowlist: list markers, date strings copied from the analysis window, and
attribution labels like &quot;7-day click&quot;.</li>
<li>On failure, the model gets <strong>one</strong> retry that names the exact keys or tokens
that broke the rule.</li>
<li>If the retry fails too, the offending sentence is dropped and a warning is
shown. A shorter answer is better than a wrong one.</li>
</ul>
<p>The eval suite checks for zero ungrounded numbers on every recorded question,
not just for the &quot;right&quot; answer.</p>
<h2 id="the-audit-five-places-numbers-still-got-through">The audit: five places numbers still got through <a class="anchor" href="https://shivamsingh262.com/notes/code-computes-llm-interprets/#the-audit-five-places-numbers-still-got-through" aria-label="Link to this section">#</a></h2>
<p>Placeholders covered the obvious case. When I audited every LLM call in the
pipeline, five places still let an LLM-written or LLM-chosen number through:</p>
<ol>
<li><strong>Dates and period lengths.</strong> The validator had exempted ISO dates, month
names and &quot;N days&quot;. So the model could write &quot;over the last 45 days&quot; when the
window was 28. Now the answer step receives every window the turn actually
used, and any date or duration outside them is a violation.</li>
<li><strong>Named periods.</strong> The planner was turning &quot;last month&quot; or &quot;Q3&quot; into date
ranges itself, which meant the LLM did date arithmetic. Common phrases
(&quot;last 30 days&quot;, &quot;in September&quot;, &quot;year to date&quot;, &quot;yesterday&quot;) are now parsed
in code. Only phrases that are ambiguous or match nothing go to the planner.</li>
<li><strong>Conceptual answers.</strong> For &quot;what&#39;s a good CTR?&quot; there&#39;s no data, so the model
reached for benchmarks it half-remembered. Those answers may now only repeat
numbers the user wrote, and the prompt asks for explanations in words.</li>
<li><strong>Follow-up suggestions.</strong> &quot;Want me to look at why CPA is up 40%?&quot; is still a
number. Suggestions that state a figure without a fact behind it are dropped.</li>
<li><strong>Planner filters.</strong> &quot;Show me campaigns with ROAS below 2&quot; is fine. But the
planner also invented thresholds for vague wording like &quot;underperforming
campaigns&quot;. A <code>gt</code>/<code>lt</code> filter is now kept only if the user stated that number;
otherwise the planner sorts and limits instead.</li>
</ol>
<p>The pattern: the LLM chose a number in places that didn&#39;t look like &quot;writing a
number&quot;, such as a date range, a filter or a follow-up question.</p>
<h2 id="small-rules-that-saved-retries">Small rules that saved retries <a class="anchor" href="https://shivamsingh262.com/notes/code-computes-llm-interprets/#small-rules-that-saved-retries" aria-label="Link to this section">#</a></h2>
<p>Strict validation costs retries, and each retry is another full LLM call. A few
fixes in code removed the most common ones:</p>
<ul>
<li><strong>Currency before a placeholder.</strong> The model kept writing
<code>INR {{fact:spend}}</code> for a fact that already renders as &quot;INR 60K&quot;. Code now
strips the typed currency when the fact brings its own, rather than failing the
whole draft.</li>
<li><strong>Figures from other findings.</strong> Each finding may name other findings by
title. But it may only quote its own fact labels and text values, so it can&#39;t
repeat another finding&#39;s &quot;over 90%&quot; outside that finding&#39;s context.</li>
<li><strong>Streaming.</strong> Answers now stream to the UI sentence by sentence. A sentence
already on screen can&#39;t be rewritten, so a streamed sentence that fails
validation is dropped instead of retried.</li>
</ul>
<h2 id="what-it-costs">What it costs <a class="anchor" href="https://shivamsingh262.com/notes/code-computes-llm-interprets/#what-it-costs" aria-label="Link to this section">#</a></h2>
<p>The prose is stiffer. Without the freedom to say &quot;nearly double&quot; or &quot;about a
third&quot;, the model writes more literal sentences. Some sentences get dropped.
Retries add latency.</p>
<p>I&#39;ll take that trade every time. Users have to trust the numbers before the
interpretation is worth anything to them.</p>
<p>I&#39;ve released the core of this as an open-source Python library,
<a href="https://github.com/shivamsingh262/factguard">factguard</a>: fact placeholders,
the validator with its date windows, the single retry and dropping the sentence.</p>
<p><em>Related: <a href="https://shivamsingh262.com/notes/llm-output-token-cap/">When an LLM hits its output cap</a>, on what happened when one of these interpretation calls outgrew its token limit.</em></p>
]]></content:encoded>
  </item>
</channel>
</rss>
