<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[haohaowan]]></title><description><![CDATA[haohaowan]]></description><link>https://emoji-combos.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>haohaowan</title><link>https://emoji-combos.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Mon, 12 Oct 2026 09:41:21 GMT</lastBuildDate><atom:link href="https://emoji-combos.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Designing an AI Pipeline That Cannot Hallucinate]]></title><description><![CDATA[On emoji-combos.net, a language model drafts decorative emoji combos for social bios whenever a visitor searches a vibe the site hasn't curated. The architectural requirement I set for that feature re]]></description><link>https://emoji-combos.hashnode.dev/designing-an-ai-pipeline-that-cannot-hallucinate</link><guid isPermaLink="true">https://emoji-combos.hashnode.dev/designing-an-ai-pipeline-that-cannot-hallucinate</guid><category><![CDATA[Artificial Intelligence]]></category><category><![CDATA[software architecture]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[System Design]]></category><dc:creator><![CDATA[yangningchuan]]></dc:creator><pubDate>Thu, 08 Oct 2026 12:07:30 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/68711ec018a622f80982fdd9/a0b9cb4b-0878-4f7a-9ac4-9318b5bda0b1.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>On <a href="https://emoji-combos.net">emoji-combos.net</a>, a language model drafts decorative emoji combos for social bios whenever a visitor searches a vibe the site hasn't curated. The architectural requirement I set for that feature reads like a joke until you think about it: <strong>the system may never publish an emoji that doesn't exist.</strong> Not "rarely." Never.</p>
<p>That guarantee is not delivered by prompting, model choice, or luck. It's delivered by architecture: a ground-truth registry enforced at a validation boundary, entity state that treats AI output as untrusted until reviewed, a caching layer that makes the model the cold-start path rather than the hot path, and failure modes that fail closed. This piece walks through that architecture, from the layers to the request lifecycle to the trade-offs I considered and rejected, in a way you can lift for any feature where an LLM produces checkable output.</p>
<p>The stack for context: Next.js on Cloudflare Workers, SQLite locally and D1 in production, a single OpenAI-compatible LLM endpoint (DeepSeek) behind a thin client. Small site, small budget, and I'd argue that's exactly where this architecture shines: it spends nothing on runtime and moves all the trust questions into places where answers are cheap.</p>
<h2>The core design decision</h2>
<p>Most "AI safety" discussions in product teams start with model behavior. This architecture starts one level down, with a question: <em>what does the system already know to be true?</em></p>
<p>For emoji, the answer is unusually clean. The Unicode standard publishes the complete registry of valid emoji sequences, 3,564 of them in the revision I imported. That transforms the safety problem from "assess output quality" (hard, fuzzy, model-flavored) into "verify output membership" (exact, computable, free). The entire architecture falls out of refusing to solve the hard version of a problem that has an easy version.</p>
<p>Generalize it: any domain with a canonical registry (country codes, drug names, SKUs, airport codes, your own accounts table) gives you the same leverage. The model proposes; the registry disposes. Where teams get into trouble is treating registry-checkable facts as if they needed judgment, or worse, inventing judgment systems (model-scoring-model) to approximate a lookup they could have had for free.</p>
<p>Everything below is the machinery around that one decision.</p>
<h2>The four layers</h2>
<p>The pipeline separates trust into four layers, each with a single responsibility. The separation matters more than any individual check: when a bad thing ships, you want to know which layer's contract broke, and when you swap the LLM provider (I've done it once already), only one layer even notices.</p>
<h3>Layer 1: The contract layer</h3>
<p>The model speaks through one function, a single-turn chat completion with JSON mode (<code>response_format: { type: 'json_object' }</code>), a fixed response shape (<code>{slug, name, description, combos[]}</code>), and a generous token ceiling. Two decisions in this layer are load-bearing.</p>
<p>First, the contract is <em>tolerantly</em> parsed. Even in JSON mode, models occasionally wrap the payload in markdown fences or open with a sentence. The parser strips fences, then falls back to slicing from the first <code>{</code> to the last <code>}</code>, then validates the payload's <em>shape</em> field by field before any value is trusted. <code>payload.combos</code> being <code>null</code> should be an explicit, named error, not a <code>TypeError</code> three frames later.</p>
<p>Second, the token ceiling budgets for hidden reasoning. The production model spends reasoning tokens before emitting JSON, and reasoning plus body share one <code>max_tokens</code> budget. A ceiling sized for the visible output alone produces intermittent empty completions that look like network flakes. (This cost me an evening once; the ceiling is now 4,000 tokens for output that needs a few hundred.)</p>
<p>The layer's philosophy: <strong>the model is a helpful but sloppy API client.</strong> Treat its output like user input, because it has the same relationship to truth that user input does.</p>
<h3>Layer 2: The validation boundary</h3>
<p>Validation runs before anything persists. Three checks, in one pass over each proposed combo:</p>
<ol>
<li><p><strong>Grapheme segmentation</strong> via <code>Intl.Segmenter</code>: the unit of validation is the user-perceived character, never the code point, because joined sequences (families, multi-person poses) shatter under <code>[...str]</code> into fragments that individually look valid and collectively aren't.</p>
</li>
<li><p><strong>Dictionary membership</strong>: every emoji-looking grapheme (detected via <code>\p{Extended_Pictographic}</code>, with skin-tone modifiers stripped to collapse mixed-tone variants onto enumerated bases) must exist in the imported Unicode registry, held as an in-memory <code>Set</code>.</p>
</li>
<li><p><strong>Invisible character rejection</strong>: zero-width and bidi control characters are rejected outright; on platforms that parse pasted bios for mentions and tags, they're a known injection primitive.</p>
</li>
</ol>
<p>The boundary's contract is absolute: anything crossing it is either registry-verified or nonexistent to the rest of the system. Downstream code never re-checks emoji validity, because upstream made the question meaningless.</p>
<img src="https://cdn.hashnode.com/uploads/covers/68711ec018a622f80982fdd9/6028525c-8583-4bfd-bd95-e4e47d9b8a3d.jpg" alt="A vault door opening onto shelves of glowing emoji stickers, a robot clerk with a clipboard checking items" style="display:block;margin:0 auto" />

<h3>Layer 3: The persistence layer</h3>
<p>The database gets three rules the model cannot talk its way past.</p>
<p><strong>AI output lands in a draft state.</strong> A generated vibe is created with <code>status: 'draft'</code>, which excludes it from public listings, the sitemap, and featured placement. It becomes a permanent, indexable page only when an admin promotes it from the review queue. The searcher who triggered the generation sees results in their session; the <em>site</em> commits to nothing until a human has said yes.</p>
<p><strong>Uniqueness is a constraint, not a hope.</strong> Combo content carries a database-level unique constraint. The model regurgitating a combo that already exists produces a tolerated unique-violation on insert, not a failed request. Dedupe is enforced where it's race-proof: two concurrent generations of the same vibe can both write, and the constraint arbitrates.</p>
<p><strong>Batches have a floor.</strong> A generation requests ten combos; fewer than four valid survivors fails the whole batch. This is the subtlest rule and the one I'd fight for. "Ship whatever survived validation" is tempting and wrong: a batch that mostly failed is a signal that something upstream drifted (prompt regression, model swap, provider quirk), and its survivors are a biased sample of a broken process. The floor converts rejection rate from silent quality decay into an observable event.</p>
<h3>Layer 4: The review queue</h3>
<p>The human layer is deliberately scoped. The queue reviews <em>taste</em>: variety within the set, on-theme-ness, whether the auto-written vibe description is any good. It never reviews correctness; dictionary membership, length, invisible characters, and duplicates were all settled deterministically before persistence.</p>
<p>That scoping is what makes a one-person review queue viable at all. Humans are the most expensive validators in the system; pointing them at questions a <code>Set</code> answers faster is architecturally wasteful, and pointing them at <em>nothing</em> (full auto-publish) is how "the site is legally a robot now" happens. The draft-until-reviewed state is the compromise: live results for the visitor, permanence gated on judgment.</p>
<h2>The request lifecycle</h2>
<p>Putting the layers in motion, for a visitor searching "haunted library":</p>
<ol>
<li><p>Search runs against curated vibes. No match → the generation path activates (if the feature is enabled at all; an absent API key disables it end to end, returning 503 and a static empty state, which is the fail-closed posture).</p>
</li>
<li><p>The query is normalized (trimmed, lowercased, length-capped) and slugified. <strong>Cache check:</strong> if that slug exists and already has six or more approved combos, serve them. The model is not called. Most requests on mature vibes die here, happily.</p>
</li>
<li><p>No cache → the contract layer requests ten combos, passing the <em>existing vibe slugs</em> along to the model so it can reuse a canonical slug for synonym queries ("可爱" should land on <code>cute</code>, not spawn a duplicate vibe).</p>
</li>
<li><p>Validation runs over the response. Dictionary, segmenter, invisible characters, within-batch dedupe.</p>
</li>
<li><p><strong>Canonical re-check:</strong> because the model may map the typed query onto an existing vibe's slug, the cache is consulted <em>again</em> under the canonical slug before creating anything. This closes the duplicate-entity race that simple "check cache → generate → insert" flows miss.</p>
</li>
<li><p>The vibe is ensured in draft state (with an idempotent create-and-read-back on unique races), valid combos persist under it, and the visitor gets their results.</p>
</li>
<li><p>Eventually, a human promotes or discards the vibe in the queue. Promotion makes it permanent and public; nothing else does.</p>
</li>
</ol>
<p>Step 5 is the one I'd highlight in any design review. Two cache checks around one model call looks redundant until you notice the model is also a <em>canonicalizer</em>, turning arbitrary human queries into canonical entity slugs, and the second check is what prevents "cute," "kawaii," "可爱," and "cute aesthetic" from becoming four vibes with four pages competing for the same intent in search.</p>
<h2>Cache economics: the model as cold-start path</h2>
<p>The threshold-six rule reframes what the LLM is <em>for</em>. It's not the serving layer; it's the bootstrap. A vibe's first few searches may hit the model; everything after serves stored, validated, human-reviewed data.</p>
<p>The economics compound in the right direction. Validation made stored output trustworthy enough to serve; serving stored output made the model rare; rarity capped both cost and hallucination surface. Meanwhile the review queue converts each cold-start generation into permanent inventory. The feature gets cheaper per request as it gets older, which is the opposite of the naive "AI feature" cost curve (stateless model-per-request) and, I suspect, the only shape that makes sense for a small site.</p>
<img src="https://cdn.hashnode.com/uploads/covers/68711ec018a622f80982fdd9/eb4c8172-91e1-4241-9c98-1cdde41092c3.jpg" alt="Two nearly identical emoji stickers under inspection, one solid and whole, one quietly dissolving at the edges" style="display:block;margin:0 auto" />

<h2>Runtime notes from the edge</h2>
<p>Two deployment realities shaped the design and generalize beyond Workers.</p>
<p><strong>Data ships with the artifact.</strong> The edge runtime has no filesystem, so the emoji registry (and the blog manifest, and everything else content-shaped) is bundled into the build and imported as a module. That's a feature wearing a constraint's clothes: the registry loads once at isolate start, membership checks hit an in-memory <code>Set</code>, and there is no "dictionary service" to page you at 3am. Ground truth that travels with the code can't drift from it.</p>
<p><strong>Writes are idempotent by construction.</strong> Edge deployments scale horizontally, and two isolates can receive the same empty-search query concurrently. The design never assumes single-flight: generation is safe to repeat, inserts race on unique constraints, and the canonical-slug re-check keeps entity creation convergent. Any correctness that depends on "only one request at a time" is correctness you don't have.</p>
<h2>What to measure</h2>
<p>The pipeline's health signal is one number: <strong>validation rejection rate per batch.</strong> Steady-state single digits; a spike means prompt drift, a provider change, or a Unicode revision the dictionary hasn't imported yet. The batch floor (four-of-ten) turns that signal into an enforced threshold rather than a dashboard you stop reading. Rejection <em>reasons</em> are nearly as cheap to log: invalid graphemes versus invisible characters versus shape failures tell three different stories about what's wrong upstream.</p>
<p>What I deliberately don't measure: model-side scores, self-reported confidence, "quality" percentages. In a registry-validated pipeline, those are noise adjacent to a ground truth you already own.</p>
<h2>Trade-offs and rejected alternatives</h2>
<p><strong>A second model as judge.</strong> Rejected: it has the same failure mode as the first (predicting plausibility, not consulting truth), adds latency and cost, and creates a second thing to monitor. Chains of LLMs checking LLMs are theater when a <code>Set.has()</code> is exact.</p>
<p><strong>A hosted moderation/classification API.</strong> Rejected for this use: moderation models assess <em>acceptability</em>, not <em>existence</em>, and the requirement here is existence. (Moderation APIs are the right tool when the risk is tone or policy, not ontology.)</p>
<p><strong>Prompt-only containment</strong> ("only use valid emoji, double-check each one"). Rejected as a category: prompts are suggestions executed by a token predictor. This layer still exists (the system prompt asks for variety and reuse of canonical slugs), but it steers <em>quality</em>, never <em>correctness</em>.</p>
<p><strong>Full auto-publish with post-hoc cleanup.</strong> Rejected: the cost asymmetry is wrong. A bad vibe page is indexed, shared, and linked before cleanup; the draft gate costs one human minute per vibe and makes that class of accident structurally impossible.</p>
<h2>Takeaways for your own pipeline</h2>
<ul>
<li><p>Find the registry first. If your domain has a canonical list of what's real, validation is a lookup and "AI safety" becomes an engineering problem with a right answer.</p>
</li>
<li><p>Draw a hard validation boundary and let nothing cross unverified. Downstream code should be able to <em>forget</em> that AI was involved.</p>
</li>
<li><p>Treat the model as a sloppy API client: tolerant parsing, shape validation, budgets sized for hidden reasoning, fail-closed on absence.</p>
</li>
<li><p>Use the model as a cold-start path, cache into reviewed inventory, and let the feature get cheaper as it ages.</p>
</li>
<li><p>Keep humans where judgment lives. Scoping review to taste (not correctness) is what makes a one-person queue sustainable.</p>
</li>
<li><p>Enforce idempotency in the data layer; never trust single-flight on horizontally scaled runtimes.</p>
</li>
</ul>
<p>The system in the wild is <a href="https://emoji-combos.net">emoji-combos.net</a>: the <a href="https://emoji-combos.net/ai-emoji-combos">AI-assisted search</a> exercises this pipeline end to end, and the <a href="https://emoji-combos.net/blog/emoji-combos-guide">field guide</a> shows the human-curated side it feeds into. If you're designing something similar, I'm genuinely curious where your registry hides; the comments are open.</p>
<h2>FAQ</h2>
<p><strong>Can this architecture reach literally zero hallucinated emoji in production?</strong> For the property "published emoji exist in the Unicode registry," yes, because the check is deterministic and enforced before persistence. What it can't guarantee is taste or on-theme-ness; that's the review queue's job, and it's subjective by definition. The honest claim is "zero <em>checkable</em> defects," which happens to be most of what users would call broken.</p>
<p><strong>How does the pipeline handle a new Unicode emoji release?</strong> Re-import the registry, rebuild, redeploy. The dictionary is bundled data, so the validation boundary itself never changes shape. Between releases, brand-new real emoji are treated as invalid until imported, a safe default since the alternative is accepting unverified sequences.</p>
<p><strong>Doesn't the second cache check add latency?</strong> One indexed slug lookup against a warm database, negligible next to the LLM call it precedes, and it only runs on the generation path, which the cache usually short-circuits anyway.</p>
<p><strong>What breaks if the provider silently changes behavior?</strong> The contract layer's tolerant parsing absorbs format drift; the batch floor catches quality drift by failing loudly; the review queue is the final net for whatever is technically valid but wrong. The design assumes provider drift is a <em>when</em>, not an <em>if</em>.</p>
<p><strong>Is the human review queue a scaling bottleneck?</strong> It's a deliberation point, and it's automatable in tooling without automating the decision. What shouldn't scale away is the draft-until-reviewed state itself: bulk-approve tools are fine, auto-publish is the line, because the queue is the only layer that can notice "everything is correct and none of it is good."</p>
]]></content:encoded></item></channel></rss>