<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Maker Forem</title>
    <description>The most recent home feed on Maker Forem.</description>
    <link>https://maker.forem.com</link>
    <atom:link rel="self" type="application/rss+xml" href="https://maker.forem.com/feed"/>
    <language>en</language>
    <item>
      <title>CSS Color Contrast: The WCAG Rules Every Developer Should Know</title>
      <dc:creator>Snappy Tools</dc:creator>
      <pubDate>Mon, 11 May 2026 10:08:09 +0000</pubDate>
      <link>https://maker.forem.com/snappy_tools/css-color-contrast-the-wcag-rules-every-developer-should-know-4gpo</link>
      <guid>https://maker.forem.com/snappy_tools/css-color-contrast-the-wcag-rules-every-developer-should-know-4gpo</guid>
      <description>&lt;p&gt;Color contrast is one of the most commonly overlooked accessibility requirements in web development — and one of the easiest to fail accidentally. You pick a beautiful grey text on a white background, it looks fine on your calibrated monitor, and then someone with low vision or a washed-out phone screen can't read it at all.&lt;/p&gt;

&lt;p&gt;This post covers how contrast ratios work, what WCAG requires, and how to check your colors before they ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is a Contrast Ratio?
&lt;/h2&gt;

&lt;p&gt;The contrast ratio between two colors is a number from 1:1 (no contrast — same color) to 21:1 (maximum contrast — black on white). It is calculated from the &lt;strong&gt;relative luminance&lt;/strong&gt; of each color, which is a perceptual measure of how bright a color appears to the human eye.&lt;/p&gt;

&lt;p&gt;The formula is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;contrast ratio = (L1 + 0.05) / (L2 + 0.05)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;where L1 is the luminance of the lighter color and L2 is the luminance of the darker one. Luminance itself is computed from the RGB values after gamma-correcting them (which is why the math is not as simple as just comparing hex codes).&lt;/p&gt;

&lt;p&gt;Fortunately, you do not need to calculate this by hand. &lt;a href="https://snappytools.app/color-contrast-checker/" rel="noopener noreferrer"&gt;SnappyTools has a free Color Contrast Checker&lt;/a&gt; that computes the ratio instantly and shows you exactly which WCAG levels you pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  WCAG 2.1 Requirements
&lt;/h2&gt;

&lt;p&gt;The Web Content Accessibility Guidelines define three conformance levels — A, AA, and AAA — and contrast requirements that differ by text size:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Element&lt;/th&gt;
&lt;th&gt;AA minimum&lt;/th&gt;
&lt;th&gt;AAA enhanced&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Normal text (&amp;lt; 18pt or &amp;lt; 14pt bold)&lt;/td&gt;
&lt;td&gt;4.5:1&lt;/td&gt;
&lt;td&gt;7:1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Large text (≥ 18pt or ≥ 14pt bold)&lt;/td&gt;
&lt;td&gt;3:1&lt;/td&gt;
&lt;td&gt;4.5:1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UI components and graphics&lt;/td&gt;
&lt;td&gt;3:1&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Level AA&lt;/strong&gt; is the legal baseline in most jurisdictions (it's what the ADA, EN 301 549, and WCAG 2.1 compliance frameworks reference). &lt;strong&gt;Level AAA&lt;/strong&gt; is aspirational — aim for it on body text where possible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Failures (And How to Spot Them)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Light grey on white
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* Fails AA — ratio ~4.1:1 */&lt;/span&gt;
&lt;span class="nt"&gt;color&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="err"&gt;#767676&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="nt"&gt;background&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;#ffffff&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The classic mistake. &lt;code&gt;#767676&lt;/code&gt; on white is almost exactly at the AA threshold and fails it narrowly. Use &lt;code&gt;#595959&lt;/code&gt; or darker for reliable compliance.&lt;/p&gt;

&lt;h3&gt;
  
  
  Brand colors with white text
&lt;/h3&gt;

&lt;p&gt;Bright brand colors frequently fail:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;#FF6B6B&lt;/code&gt; (coral) on white: ~3.0:1 — fails AA for normal text&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;#FFD700&lt;/code&gt; (gold) on white: ~1.7:1 — fails everything&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your brand color fails contrast on white, either darken the shade for UI text or use dark text on the brand color instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  Placeholder text
&lt;/h3&gt;

&lt;p&gt;CSS &lt;code&gt;::placeholder&lt;/code&gt; styling inherits from the input but is typically rendered at reduced opacity. The effective contrast of &lt;code&gt;opacity: 0.5&lt;/code&gt; on a &lt;code&gt;#555&lt;/code&gt; placeholder over white is much lower than &lt;code&gt;#555&lt;/code&gt; measured directly. Check your placeholder styling explicitly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Link underline color
&lt;/h3&gt;

&lt;p&gt;When &lt;code&gt;text-decoration-color&lt;/code&gt; is set separately from &lt;code&gt;color&lt;/code&gt;, both need adequate contrast. A common pattern that fails: grey underline color on a white background used to subtly de-emphasise links.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking Contrast in Your Workflow
&lt;/h2&gt;

&lt;p&gt;There are three points where it's worth checking:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. During design&lt;/strong&gt; — before a pixel is written to CSS. Design tools like Figma have contrast plugins (Stark, Contrast) that flag issues while you're still moving colors around.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. During development&lt;/strong&gt; — when you translate the design to code. Use a browser-based tool so you can quickly iterate on hex values. The &lt;a href="https://snappytools.app/color-contrast-checker/" rel="noopener noreferrer"&gt;Color Contrast Checker at SnappyTools&lt;/a&gt; lets you enter hex codes or use a color picker and shows the ratio and pass/fail for all WCAG levels at once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. During audit&lt;/strong&gt; — before shipping. Chrome DevTools has a contrast ratio indicator in the color picker (accessible when inspecting an element). axe DevTools and Lighthouse both flag contrast failures automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Reference: Ratios That Always Pass
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Ratio&lt;/th&gt;
&lt;th&gt;Passes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;≥ 7:1&lt;/td&gt;
&lt;td&gt;AAA normal text, AAA large text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;≥ 4.5:1&lt;/td&gt;
&lt;td&gt;AA normal text, AAA large text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;≥ 3:1&lt;/td&gt;
&lt;td&gt;AA large text, AA UI components&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&amp;lt; 3:1&lt;/td&gt;
&lt;td&gt;Fails everything&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Text Over Images and Gradients
&lt;/h2&gt;

&lt;p&gt;WCAG contrast rules assume a flat background. When text sits over an image or gradient, you need to ensure the minimum contrast is met at the worst-case point in the image.&lt;/p&gt;

&lt;p&gt;Common solutions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Text shadow&lt;/strong&gt; — &lt;code&gt;text-shadow: 0 1px 4px rgba(0,0,0,0.8)&lt;/code&gt; works but can look harsh&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Semi-transparent scrim&lt;/strong&gt; — &lt;code&gt;background: linear-gradient(transparent, rgba(0,0,0,0.6))&lt;/code&gt; from the bottom up&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Solid background strip&lt;/strong&gt; — old-fashioned but reliable&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are checkable by automated tools — you need a human eye (or a per-pixel luminance calculation) to verify.&lt;/p&gt;

&lt;h2&gt;
  
  
  One Last Thing: Colour Is Not the Only Signal
&lt;/h2&gt;

&lt;p&gt;WCAG 1.4.1 says you cannot convey information using colour alone. A red/green "error/success" indicator needs a second signal (icon, label, border shape) to be accessible to colour-blind users. This is separate from the contrast ratio — a fully accessible red error state has high contrast &lt;em&gt;and&lt;/em&gt; an error icon.&lt;/p&gt;




&lt;p&gt;Color contrast takes about 30 seconds to check per color pair. Making it part of your review workflow is one of the highest-ROI accessibility habits you can build.&lt;/p&gt;

&lt;p&gt;Use the &lt;a href="https://snappytools.app/color-contrast-checker/" rel="noopener noreferrer"&gt;free Color Contrast Checker&lt;/a&gt; — no signup, runs in your browser, shows WCAG AA and AAA results instantly.&lt;/p&gt;

</description>
      <category>css</category>
      <category>webdev</category>
      <category>a11y</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Q-Learning for Games: Teaching an Agent Tic-Tac-Toe Through Self-Play</title>
      <dc:creator>Berkan Sesen</dc:creator>
      <pubDate>Mon, 11 May 2026 10:07:25 +0000</pubDate>
      <link>https://maker.forem.com/berkan_sesen/q-learning-for-games-teaching-an-agent-tic-tac-toe-through-self-play-3n6d</link>
      <guid>https://maker.forem.com/berkan_sesen/q-learning-for-games-teaching-an-agent-tic-tac-toe-through-self-play-3n6d</guid>
      <description>&lt;p&gt;Tic-tac-toe is a solved game. Any competent adult can force a draw every time. But can an agent figure that out with zero human knowledge? Give two agents a blank board, a few simple rules about wins and losses, and nothing else. No opening theory, no strategy guides, no human games to study. After 100,000 games of fumbling against each other, they discover forks, blocking, and centre-first openings entirely on their own.&lt;/p&gt;

&lt;p&gt;This is Q-learning applied to games. In our &lt;a href="https://sesen.ai/blog/q-learning-frozen-lake-from-scratch" rel="noopener noreferrer"&gt;previous Q-learning post&lt;/a&gt;, the agent navigated a frozen lake alone, learning from its own mistakes. Here, we add an opponent. The agent can't just learn the environment; it must learn to &lt;em&gt;outsmart&lt;/em&gt; another learner who's improving at the same time.&lt;/p&gt;

&lt;p&gt;By the end of this post, you'll build two Q-learning agents that teach each other tic-tac-toe through self-play, and you'll understand why this simple setup discovers remarkably strong strategy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem: Tic-Tac-Toe as an RL Environment
&lt;/h2&gt;

&lt;p&gt;Tic-tac-toe is the simplest non-trivial two-player game. The board has 9 cells, two players alternate placing X and O, and the first to complete a row, column, or diagonal wins. If all cells are filled with no winner, it's a draw.&lt;/p&gt;

&lt;p&gt;As an RL problem:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;State&lt;/strong&gt;: the current board (which cells have X, O, or are empty)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Actions&lt;/strong&gt;: place your marker on any empty cell&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reward&lt;/strong&gt;: +1 for winning, -1 for losing, 0 for a draw or an ongoing game&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transition&lt;/strong&gt;: deterministic (unlike the slippery FrozenLake), but the opponent's move is stochastic from your perspective&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The state space is manageable: there are at most &lt;code&gt;$3^9 = 19{,}683$&lt;/code&gt; possible board configurations (fewer in practice, since many are unreachable). This makes tabular Q-learning a perfect fit, with no need for &lt;a href="https://sesen.ai/blog/deep-q-networks-experience-replay-target-networks" rel="noopener noreferrer"&gt;neural network function approximation&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Win: Self-Play in Action
&lt;/h2&gt;

&lt;p&gt;Let's see two Q-learning agents teach each other from scratch. Click the badge to run this yourself:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://colab.research.google.com/github/zhubarb/sesen_ai_ml_tutorials/blob/main/notebooks/reinforcement-learning/tic_tac_toe_q_learning.ipynb" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcolab.research.google.com%2Fassets%2Fcolab-badge.svg" alt="Open In Colab" width="117" height="20"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Watch how the agents' play evolves from random moves (early training) to strategic play (late training):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj7g70ecuu7bdsrs6nxzn.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj7g70ecuu7bdsrs6nxzn.gif" alt="Skill progression over training, from random moves to strategic blocking and winning" width="600" height="700"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here's the complete implementation. We need three pieces: an environment, an agent, and a self-play training loop.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TicTacToe&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Tic-tac-toe environment. Board is a flat array of 9 cells.
    Values: 0=empty, 1=X, -1=O.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;zeros&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dtype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;reset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;zeros&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dtype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;available_actions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;marker&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;marker&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_is_winner&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;win&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;available_actions&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;draw&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ongoing&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_is_winner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reshape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[:,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;diag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;diag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fliplr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent is a standard Q-learner with one key adaptation: Q-values for occupied cells are set to &lt;code&gt;NaN&lt;/code&gt; so the agent never tries to play in a taken position.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;QLearningAgent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;marker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;epsilon&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lr&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                 &lt;span class="n"&gt;gamma&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.95&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;final_epsilon&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;marker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;marker&lt;/span&gt;       &lt;span class="c1"&gt;# 1 for X, -1 for O
&lt;/span&gt;        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;epsilon&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;epsilon&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lr&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;gamma&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;gamma&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;final_epsilon&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;final_epsilon&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;q_table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;          &lt;span class="c1"&gt;# {tuple(state): np.array(9)}
&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_get_q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;q_table&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;full&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nan&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;    &lt;span class="c1"&gt;# only empty cells get Q-values
&lt;/span&gt;            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;q_table&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;q_table&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;pick_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;available&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rand&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;epsilon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;available&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_get_q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;available_q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;available&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;max_q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;available_q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;available_q&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;max_q&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;best&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reward&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;next_state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_get_q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;reward&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;next_q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_get_q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;next_state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;reward&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;gamma&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;nanmax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;next_q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lr&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the self-play training loop. Both agents learn simultaneously, with the loser receiving a -1 reward when the other wins:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;env&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TicTacToe&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;agent_x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;QLearningAgent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;marker&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;epsilon&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lr&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gamma&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.95&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;agent_o&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;QLearningAgent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;marker&lt;/span&gt;&lt;span class="o"&gt;=-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;epsilon&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lr&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gamma&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.95&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;eps_decay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;2.5e-5&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;ep&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100_000&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reset&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;agents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;agent_x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;agent_o&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;agents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;agent_o&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;agent_x&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# randomise who goes first
&lt;/span&gt;    &lt;span class="n"&gt;turn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="n"&gt;history&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="n"&gt;done&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;agents&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;turn&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pick_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;next_state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reward&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;marker&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reward&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;next_state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="c1"&gt;# winner learns from the final move
&lt;/span&gt;            &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reward&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;next_state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="c1"&gt;# loser learns too: propagate -reward to their last move
&lt;/span&gt;            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;win&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;other&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;agents&lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;turn&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
                &lt;span class="n"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
                &lt;span class="n"&gt;other&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;reward&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;next_state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reward&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;next_state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;next_state&lt;/span&gt;
        &lt;span class="n"&gt;turn&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;

    &lt;span class="c1"&gt;# decay epsilon for both agents
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;agent_x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;agent_o&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;epsilon&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;final_epsilon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;epsilon&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;eps_decay&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After training, both agents win around 85% of games against a random opponent (85% for X, 84% for O):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fviarlzefel54rvsqjqbf.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fviarlzefel54rvsqjqbf.webp" alt="Both agents win around 85% against a random opponent after 100k episodes of self-play" width="800" height="339"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You just trained two agents to play tic-tac-toe without teaching them a single strategy. Let's understand how.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Just Happened?
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Board as State, Cells as Actions
&lt;/h3&gt;

&lt;p&gt;The environment represents the board as a flat array of 9 integers: &lt;code&gt;1&lt;/code&gt; for X, &lt;code&gt;-1&lt;/code&gt; for O, &lt;code&gt;0&lt;/code&gt; for empty. This encoding is compact and makes win detection elegant. A row, column, or diagonal sums to +3 (X wins) or -3 (O wins).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Check rows, columns, diagonals
&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reshape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="c1"&gt;# row i
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[:,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="c1"&gt;# column i
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The action space is the set of empty cells. Using &lt;code&gt;NaN&lt;/code&gt; for occupied positions in the Q-table means the agent physically cannot select an illegal move, as &lt;code&gt;np.nanmax&lt;/code&gt; ignores &lt;code&gt;NaN&lt;/code&gt; values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;full&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nan&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;  &lt;span class="c1"&gt;# only legal moves get Q-values
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Self-Play: The Opponent is the Curriculum
&lt;/h3&gt;

&lt;p&gt;The key insight of self-play is that both agents improve together. In early training, epsilon (the probability of choosing a random action instead of the greedy one) starts at 1.0, so both play nearly randomly and wins and losses are noisy. As epsilon decays linearly towards 0.05, they exploit what they've learned, and the opponent becomes a tougher challenge.&lt;/p&gt;

&lt;p&gt;This creates an &lt;strong&gt;arms race&lt;/strong&gt;. Watch the training curve:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fox7kvhodn8i88mwqhjg7.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fox7kvhodn8i88mwqhjg7.webp" alt="Self-play training dynamics: draw rate rises from 10% to over 40% as both agents improve" width="800" height="445"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three things happen as training progresses:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Draw rate rises&lt;/strong&gt; from ~10% to ~42%. Both agents get better at defending, so fewer games end in a clear win.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Win rates equalise&lt;/strong&gt;. X starts with a slight advantage (going first), but by the end, both hover around 30%.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The transition is sharp&lt;/strong&gt;. Around episode 30,000, epsilon has decayed enough that agents exploit their Q-values more than they explore. The draw rate shoots up.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Reward Propagation in Adversarial Games
&lt;/h3&gt;

&lt;p&gt;In single-agent Q-learning (like &lt;a href="https://sesen.ai/blog/q-learning-frozen-lake-from-scratch" rel="noopener noreferrer"&gt;FrozenLake&lt;/a&gt;), the agent updates after every step. In a two-player game, we need an extra mechanism: when one agent wins, the &lt;strong&gt;loser&lt;/strong&gt; must also learn from its last move.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;win&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;other&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;agents&lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;turn&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;other&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;reward&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;next_state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The winner gets reward +1. The loser's last move gets -1. This is how the agent learns defensive play: "the move I made two turns ago led to my opponent winning, so that was a bad move."&lt;/p&gt;

&lt;h3&gt;
  
  
  Reading the Q-Values
&lt;/h3&gt;

&lt;p&gt;The Q-table is where the agent's strategy lives. Each entry says: "from this board state, how good is it to play in cell X?" Let's look at three critical situations the agent learned to handle:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fro60e1ws5rr2zzsav7i5.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fro60e1ws5rr2zzsav7i5.webp" alt="Three board states showing the agent's learned Q-values: forking, blocking, and winning" width="800" height="296"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Left panel (Set Up a Fork):&lt;/strong&gt; X has the centre and top-left corner. The agent assigns Q = +0.85 to the bottom-right corner (position 8), which creates a &lt;strong&gt;fork&lt;/strong&gt;: two ways to win that the opponent can't both block. Every other empty cell gets Q = 0.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Centre panel (Block or Lose):&lt;/strong&gt; O has positions 0 and 3, threatening to complete the left column. The Q-values here are all negative except position 6 (Q = 0.00), the blocking move. The agent learned that &lt;em&gt;not&lt;/em&gt; blocking leads to certain defeat. Notice the agent didn't just learn that position 6 is good; it learned that every other option is &lt;em&gt;bad&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Right panel (Take the Win):&lt;/strong&gt; X has positions 0 and 1, one move away from completing the top row. Position 2 gets Q = +0.81. The agent learned to finish the game when the opportunity is there, rather than play elsewhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Going Deeper
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fb1txyp5983cjyg02965d.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fb1txyp5983cjyg02965d.webp" alt="The self-play training loop: two agents improve simultaneously by playing each other" width="800" height="446"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Q-Learning in Games vs Single-Agent Environments
&lt;/h3&gt;

&lt;p&gt;In a single-agent setting like &lt;a href="https://sesen.ai/blog/q-learning-frozen-lake-from-scratch" rel="noopener noreferrer"&gt;FrozenLake&lt;/a&gt; or &lt;a href="https://sesen.ai/blog/value-iteration-q-learning-dynamic-programming-meets-rl" rel="noopener noreferrer"&gt;Value Iteration on a grid world&lt;/a&gt;, the environment is stationary. The transition probabilities don't change. In a game with self-play, the "environment" includes the opponent, and the opponent is changing constantly.&lt;/p&gt;

&lt;p&gt;This means Q-learning in games violates a core assumption: stationarity. The Markov property still holds (the board state contains all relevant information), but the transition dynamics shift as the opponent improves. In practice, this works because both agents improve gradually, and the learning rate is high enough to track the changing opponent.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Learning Rate = 1 Choice
&lt;/h3&gt;

&lt;p&gt;You might have noticed &lt;code&gt;lr=1.0&lt;/code&gt;, which seems aggressive. With &lt;code&gt;$\alpha = 1$&lt;/code&gt;, each Q-update completely replaces the old value:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Flatex.codecogs.com%2Fpng.image%3F%255Cdpi%257B150%257DQ%28s%252C%2520a%29%2520%255Cleftarrow%2520r%2520%252B%2520%255Cgamma%2520%255Cmax_%257Ba%27%257D%2520Q%28s%27%252C%2520a%27%29" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Flatex.codecogs.com%2Fpng.image%3F%255Cdpi%257B150%257DQ%28s%252C%2520a%29%2520%255Cleftarrow%2520r%2520%252B%2520%255Cgamma%2520%255Cmax_%257Ba%27%257D%2520Q%28s%27%252C%2520a%27%29" alt="equation" width="305" height="36"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This works for tic-tac-toe because the game is &lt;strong&gt;deterministic&lt;/strong&gt;: from a given board state, taking a specific action always produces the same next state (your move is deterministic; only the opponent's response varies). With &lt;code&gt;$\alpha = 1$&lt;/code&gt;, the agent always uses the most recent outcome, which adapts quickly to the opponent's evolving strategy.&lt;/p&gt;

&lt;p&gt;For stochastic environments, &lt;code&gt;$\alpha = 1$&lt;/code&gt; would be catastrophic, as it would forget everything from past experience. But for deterministic transitions in a game, it's ideal.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Self-Play Arms Race
&lt;/h3&gt;

&lt;p&gt;Self-play training has a characteristic signature: the draw rate is a proxy for skill. When two beginners play, most games end in wins (because both make exploitable mistakes). When two experts play, most games end in draws (because neither makes a mistake worth exploiting).&lt;/p&gt;

&lt;p&gt;Tic-tac-toe with perfect play from both sides is provably a draw. Our agents' ~42% draw rate suggests they're strong but not perfect: they're still occasionally making mistakes that the opponent can exploit.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hyperparameter Sensitivity
&lt;/h3&gt;

&lt;p&gt;The original code uses these values, all from the &lt;a href="https://github.com/zhubarb/sesen_ai_ml_tutorials" rel="noopener noreferrer"&gt;source implementation&lt;/a&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parameter&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gamma&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.95&lt;/td&gt;
&lt;td&gt;Games are short (5-9 moves), so moderate discounting works. Higher values (0.99) also work.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;lr&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1.0&lt;/td&gt;
&lt;td&gt;Deterministic transitions; always use the latest outcome.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;epsilon&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1.0 to 0.05&lt;/td&gt;
&lt;td&gt;Start fully random, end mostly greedy.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;eps_decay&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2.5e-5&lt;/td&gt;
&lt;td&gt;Linear decay over ~38,000 episodes to reach &lt;code&gt;final_epsilon&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;episodes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;100,000&lt;/td&gt;
&lt;td&gt;Enough for the Q-table to converge on the ~6,600 reachable states.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Q-table ends up with roughly 6,600 entries (out of the theoretical 19,683 board configurations). Many configurations are unreachable in valid play (e.g., a board where X has played 5 times but O has played once).&lt;/p&gt;

&lt;h3&gt;
  
  
  When NOT to Use Tabular Q-Learning for Games
&lt;/h3&gt;

&lt;p&gt;Tabular Q-learning works beautifully for tic-tac-toe because the state space is tiny. It fails for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Chess&lt;/strong&gt; (&lt;code&gt;$\sim 10^{44}$&lt;/code&gt; legal positions): the Q-table would be impossibly large&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Go&lt;/strong&gt; (&lt;code&gt;$\sim 10^{170}$&lt;/code&gt;): even worse&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Games with continuous state spaces&lt;/strong&gt;: no table can hold them&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For these, you need function approximation: &lt;a href="https://sesen.ai/blog/deep-q-networks-experience-replay-target-networks" rel="noopener noreferrer"&gt;deep Q-networks&lt;/a&gt; replace the table with a neural network, or &lt;a href="https://sesen.ai/blog/policy-gradients-reinforce-from-scratch" rel="noopener noreferrer"&gt;policy gradient methods&lt;/a&gt; learn a policy directly. The ideas from this post (self-play, reward propagation, exploration) carry forward directly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Comparison: Self-Play vs Teacher
&lt;/h3&gt;

&lt;p&gt;Our implementation uses self-play: both agents learn simultaneously. An alternative approach (also in the original code) trains against a &lt;strong&gt;teacher&lt;/strong&gt;, a heuristic opponent that plays well but not perfectly. Self-play has the advantage of being curriculum-free: you don't need to design a teacher, and the difficulty automatically scales with the learner's ability. The downside is that training can be unstable early on, as the quality of the training signal depends on having a reasonable opponent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where This Comes From
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Roots: Watkins and Temporal Difference Learning
&lt;/h3&gt;

&lt;p&gt;Q-learning was introduced by &lt;a href="https://www.cs.rhul.ac.uk/~chrisw/new_thesis.pdf" rel="noopener noreferrer"&gt;Chris Watkins in his 1989 PhD thesis&lt;/a&gt;, "Learning from Delayed Rewards." The core idea is that an agent can learn the value of actions without knowing the environment's dynamics, purely from the reward signal and the temporal difference between consecutive estimates.&lt;/p&gt;

&lt;p&gt;The update rule we used is exactly Watkins' formulation:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Flatex.codecogs.com%2Fpng.image%3F%255Cdpi%257B150%257DQ%28s_t%252C%2520a_t%29%2520%255Cleftarrow%2520Q%28s_t%252C%2520a_t%29%2520%252B%2520%255Calpha%2520%255Cleft%255B%2520r_%257Bt%252B1%257D%2520%252B%2520%255Cgamma%2520%255Cmax_%257Ba%257D%2520Q%28s_%257Bt%252B1%257D%252C%2520a%29%2520-%2520Q%28s_t%252C%2520a_t%29%2520%255Cright%255D" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Flatex.codecogs.com%2Fpng.image%3F%255Cdpi%257B150%257DQ%28s_t%252C%2520a_t%29%2520%255Cleftarrow%2520Q%28s_t%252C%2520a_t%29%2520%252B%2520%255Calpha%2520%255Cleft%255B%2520r_%257Bt%252B1%257D%2520%252B%2520%255Cgamma%2520%255Cmax_%257Ba%257D%2520Q%28s_%257Bt%252B1%257D%252C%2520a%29%2520-%2520Q%28s_t%252C%2520a_t%29%2520%255Cright%255D" alt="equation" width="643" height="46"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The term in brackets is the &lt;strong&gt;TD error&lt;/strong&gt;: the difference between what we expected (&lt;code&gt;$Q(s_t, a_t)$&lt;/code&gt;) and what we actually observed (&lt;code&gt;$r_{t+1} + \gamma \max_a Q(s_{t+1}, a)$&lt;/code&gt;). Learning adjusts Q towards the observed value.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://link.springer.com/article/10.1007/BF00992698" rel="noopener noreferrer"&gt;Watkins and Dayan (1992)&lt;/a&gt; later proved that Q-learning converges to optimal Q-values under certain conditions: every state-action pair must be visited infinitely often, and the learning rate must satisfy the Robbins-Monro conditions (&lt;code&gt;$\sum \alpha = \infty$&lt;/code&gt;, &lt;code&gt;$\sum \alpha^2 &amp;lt; \infty$&lt;/code&gt;). Our &lt;code&gt;$\alpha = 1$&lt;/code&gt; technically violates these conditions, but the deterministic nature of tic-tac-toe means the algorithm still converges in practice.&lt;/p&gt;

&lt;h3&gt;
  
  
  Game-Playing AI: A Brief History
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjq556tds0dmfy1uoi4y7.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjq556tds0dmfy1uoi4y7.webp" alt="Timeline of game-playing AI: from Samuel's checkers (1959) to AlphaGo (2016)" width="800" height="446"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Games have been the proving ground for AI since the field's inception. Sutton and Barto open Chapter 1 of &lt;a href="http://incompleteideas.net/book/the-book.html" rel="noopener noreferrer"&gt;Reinforcement Learning: An Introduction&lt;/a&gt; with exactly this problem: a temporal-difference learner playing tic-tac-toe. They use it to introduce the core RL concepts before any formal machinery.&lt;/p&gt;

&lt;p&gt;The lineage of game-playing RL runs deep:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Samuel (1959)&lt;/strong&gt;: Arthur Samuel's checkers program was one of the first learning programs, using a form of temporal difference learning decades before the name existed. It beat its creator.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tesauro (1995)&lt;/strong&gt;: Gerald Tesauro's &lt;a href="https://bkgm.com/articles/tesauro/tdl.html" rel="noopener noreferrer"&gt;TD-Gammon&lt;/a&gt; used temporal difference learning with a neural network to play backgammon at world-champion level. It discovered novel strategies that human experts later adopted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Silver et al. (2016)&lt;/strong&gt;: &lt;a href="https://www.nature.com/articles/nature16961" rel="noopener noreferrer"&gt;AlphaGo&lt;/a&gt; combined deep neural networks with Monte Carlo tree search and self-play to defeat the world Go champion. The self-play idea is the same as ours; only the scale is different.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;"The game of tic-tac-toe is a simple example, but it illustrates the fundamental principles of reinforcement learning: learning from interaction, temporal difference methods, and the trade-off between exploration and exploitation."&lt;br&gt;
-- Sutton &amp;amp; Barto, &lt;em&gt;Reinforcement Learning: An Introduction&lt;/em&gt; (2018), Chapter 1&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Connection to Minimax
&lt;/h3&gt;

&lt;p&gt;For a two-player, zero-sum game like tic-tac-toe, optimal play follows the &lt;strong&gt;minimax&lt;/strong&gt; principle: each player assumes the opponent plays optimally and chooses the action that maximises the minimum possible outcome.&lt;/p&gt;

&lt;p&gt;Q-learning with self-play implicitly converges towards minimax values. When both agents are learning optimally, the Q-values for X represent &lt;code&gt;$\max$&lt;/code&gt; (X wants to maximise its reward) and the Q-values for O represent &lt;code&gt;$\min$&lt;/code&gt; (O wants to minimise X's reward, which is equivalent to maximising O's own). The self-play training process, where both agents simultaneously improve, pushes the Q-values towards this minimax equilibrium.&lt;/p&gt;

&lt;p&gt;This is why our agents discover strong strategy without being told about minimax: the competitive pressure of self-play naturally drives them there.&lt;/p&gt;

&lt;h3&gt;
  
  
  Further Reading
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The original thesis:&lt;/strong&gt; &lt;a href="https://www.cs.rhul.ac.uk/~chrisw/new_thesis.pdf" rel="noopener noreferrer"&gt;Watkins (1989) "Learning from Delayed Rewards"&lt;/a&gt;, Sections 3-4 for the Q-learning algorithm&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The convergence proof:&lt;/strong&gt; &lt;a href="https://link.springer.com/article/10.1007/BF00992698" rel="noopener noreferrer"&gt;Watkins &amp;amp; Dayan (1992)&lt;/a&gt; in Machine Learning&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The RL textbook:&lt;/strong&gt; &lt;a href="http://incompleteideas.net/book/the-book.html" rel="noopener noreferrer"&gt;Sutton &amp;amp; Barto (2018)&lt;/a&gt;, Chapter 1 (tic-tac-toe example) and Chapter 6 (TD learning)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-play at scale:&lt;/strong&gt; &lt;a href="https://arxiv.org/abs/1712.01815" rel="noopener noreferrer"&gt;Silver et al. (2017) "Mastering Chess and Shogi by Self-Play"&lt;/a&gt;, the AlphaZero paper&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Next step:&lt;/strong&gt; Our &lt;a href="https://sesen.ai/blog/deep-q-networks-experience-replay-target-networks" rel="noopener noreferrer"&gt;DQN post&lt;/a&gt; shows how to replace the Q-table with a neural network for environments too large for tables&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Interactive Tools
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://sesen.ai/q-learning-visualizer" rel="noopener noreferrer"&gt;Q-Learning Visualiser&lt;/a&gt; — Watch Q-learning train step-by-step on grid worlds in the browser&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Related Posts
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://sesen.ai/blog/q-learning-frozen-lake-from-scratch" rel="noopener noreferrer"&gt;Q-Learning from Scratch: Navigating the Frozen Lake&lt;/a&gt; (tabular Q-learning fundamentals)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://sesen.ai/blog/value-iteration-q-learning-dynamic-programming-meets-rl" rel="noopener noreferrer"&gt;Value Iteration vs Q-Learning: Dynamic Programming Meets RL&lt;/a&gt; (comparing model-based and model-free approaches)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://sesen.ai/blog/deep-q-networks-experience-replay-target-networks" rel="noopener noreferrer"&gt;Deep Q-Networks: When Tables Aren't Enough&lt;/a&gt; (scaling Q-learning with neural networks)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://sesen.ai/blog/policy-gradients-reinforce-from-scratch" rel="noopener noreferrer"&gt;Policy Gradients and REINFORCE from Scratch&lt;/a&gt; (an alternative to Q-learning that learns a policy directly)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is Q-learning with self-play?
&lt;/h3&gt;

&lt;p&gt;Q-learning is a reinforcement learning algorithm that learns the value of each state-action pair by interacting with an environment. Self-play means both players are Q-learning agents training against each other. As each agent improves, it forces the other to improve too, driving both towards optimal play without needing a hand-crafted opponent.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why use self-play instead of training against a fixed opponent?
&lt;/h3&gt;

&lt;p&gt;A fixed opponent (random or rule-based) has a ceiling: once your agent exploits its weaknesses, it stops improving. Self-play creates an ever-improving curriculum because the opponent adapts alongside the learner. This naturally pushes both agents towards minimax-optimal strategies.&lt;/p&gt;

&lt;h3&gt;
  
  
  How does epsilon affect self-play training?
&lt;/h3&gt;

&lt;p&gt;Epsilon controls how often the agent takes a random action instead of its current best. Too low and the agents settle into a narrow set of positions, missing better strategies. Too high and learning is slow because actions are mostly random. Decaying epsilon over time (high early, low late) gives broad exploration first, then refined exploitation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Q-learning with self-play always converge to optimal play in tic-tac-toe?
&lt;/h3&gt;

&lt;p&gt;Yes, given enough training episodes and appropriate hyperparameters. Tic-tac-toe has a small enough state space (under 6,000 reachable positions) that tabular Q-learning can visit every state-action pair many times. The Q-values converge to the minimax equilibrium, where both agents play perfectly and every game ends in a draw.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can this approach scale to more complex games like chess or Go?
&lt;/h3&gt;

&lt;p&gt;Not with a Q-table. Chess has roughly &lt;code&gt;$10^{47}$&lt;/code&gt; positions, making tabular Q-learning impossible. For complex games, you replace the table with a neural network (Deep Q-Networks) or use policy gradient methods. AlphaGo and AlphaZero used self-play with deep neural networks and Monte Carlo tree search to master Go, chess, and shogi.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between Q-learning and minimax for game playing?
&lt;/h3&gt;

&lt;p&gt;Minimax requires a complete model of the game (all possible states and transitions) and searches the full game tree. Q-learning is model-free: it learns from experience without needing the game rules explicitly. For small games like tic-tac-toe both reach the same optimal strategy, but Q-learning generalises to environments where you cannot enumerate the full game tree.&lt;/p&gt;

</description>
      <category>reinforcementlearning</category>
      <category>gametheory</category>
    </item>
    <item>
      <title>Meme Monday</title>
      <dc:creator>Ben Halpern</dc:creator>
      <pubDate>Mon, 11 May 2026 10:06:48 +0000</pubDate>
      <link>https://maker.forem.com/ben/meme-monday-55e2</link>
      <guid>https://maker.forem.com/ben/meme-monday-55e2</guid>
      <description>&lt;p&gt;&lt;strong&gt;Meme Monday!&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Today's cover image comes from &lt;a href="https://dev.to/ben/meme-monday-1lm6"&gt;the last thread&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;DEV is an inclusive space! Humor in poor taste will be downvoted by mods.&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>watercooler</category>
      <category>jokes</category>
    </item>
    <item>
      <title>When AI writes the code, what should humans actually read?</title>
      <dc:creator>Graham Trott</dc:creator>
      <pubDate>Mon, 11 May 2026 10:04:16 +0000</pubDate>
      <link>https://maker.forem.com/gtanyware/when-ai-writes-the-code-what-should-humans-actually-read-4k1d</link>
      <guid>https://maker.forem.com/gtanyware/when-ai-writes-the-code-what-should-humans-actually-read-4k1d</guid>
      <description>&lt;p&gt;There is an open secret in the world of vibe coding. The people commissioning the work — the ones with the product idea, the domain expertise, the actual customer in mind — usually cannot read the output. They prompt, the model produces, and the result is a tower of TypeScript or Python they accept on faith because they have no way to verify it. The validation step gets quietly skipped. "It runs" becomes "it's correct."&lt;/p&gt;

&lt;p&gt;This is not a moral failing. It's a tooling problem. And I think the way out of it is hiding in plain sight.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with normal code in an AI-first workflow
&lt;/h2&gt;

&lt;p&gt;If your premise is that AI is going to do most of the routine writing of code, then the human's job shifts. We move from authors to reviewers. From "did I express this correctly?" to "did the machine express what I meant?"&lt;/p&gt;

&lt;p&gt;Reviewing is a different job from writing, and it has different tooling needs. When you're writing, you want a fast feedback loop — autocomplete, jump-to-definition, a fast test runner. When you're reviewing, you want comprehension support — context next to the code, an explanation of &lt;em&gt;why&lt;/em&gt; this section exists, and confidence that what you're reading is actually what's running.&lt;/p&gt;

&lt;p&gt;Most editors are still optimised for the writer. The reviewer has to piece things together: read the code, hunt for a docstring above it, hope the docstring still matches, then mentally verify against intent. For an experienced developer writing their own code, this can be fast. For a vibe coder reviewing AI output, it's almost impossible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things to fix
&lt;/h2&gt;

&lt;p&gt;I've been working on the editor side of this for &lt;a href="https://allspeak.ai" rel="noopener noreferrer"&gt;AllSpeak&lt;/a&gt;, a multilingual scripting language. AllSpeak allows the same programs to be written in French, German, Italian, or any other language we add. The combination of natural-language source with a review-first editor is starting to look like a real answer to the validation gap.&lt;/p&gt;

&lt;p&gt;The first screenshot below shows the editor in normal ("raw") mode, showing a documentation block followed by some code. Because of the color-coding, the eye skips over the documentation quite easily; it's not meant to be read here.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffd6t1qotbcotavsazxve.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffd6t1qotbcotavsazxve.png" alt=" " width="800" height="465"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The second screenshot shows the editor in Blocks mode displaying the same piece of code but with its documentation in the right-hand pane, making code review far simpler. On the left is a list of all the blocks, for navigation, or you can use the up and down arrows in the toolbar. This is just a starting point; the editor could have a long way to go.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5d3do9kx9idsxakm9jvd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5d3do9kx9idsxakm9jvd.png" alt=" " width="800" height="463"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Maintaining such a structure would be a daunting task without the help of AI. This is an almost free gift we should take full advantage of.&lt;/p&gt;

&lt;p&gt;There are two specific changes I'm making.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First, sections of code get a documentation block above them&lt;/strong&gt;, in a structured comment format. Nothing radical there — literate programming has done variations on this for decades. The new bit is that each block contains two SHA hashes: one for the documentation, one for the code section it describes. If either changes without the other being deliberately re-paired, the editor flags drift.&lt;/p&gt;

&lt;p&gt;This is cheap, mechanical, and solves a problem that has plagued every codebase I've ever worked on. Documentation rots silently. Cryptographic pairing makes the rot clearly audible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second, the editor gains a side-by-side mode&lt;/strong&gt; that shows one section at a time, code on one side, its documentation on the other. The reviewer sees a small, focused unit and can ask the only question that matters: does the code do what the prose says it does?&lt;/p&gt;

&lt;p&gt;That's a comparison task, not a comprehension task. Comparison is much easier than comprehension for non-experts — and that's the entire point.&lt;/p&gt;

&lt;p&gt;Of course, all of this only becomes possible when AI is doing the coding, as is increasingly the case. Human coders, however professional, don't like to maintain comprehensive documentation for their code. Documentation gets in the way of coding and is usually regarded as an imposition, so the bare minimum is all that gets written. An agent, on the other hand, has endless patience and is more than willing to take on such a task.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters more for AllSpeak than for Python
&lt;/h2&gt;

&lt;p&gt;Here's where the language choice does real work. If the code is dense Python with framework conventions a non-developer can't parse, asking "does the code match the prose?" is still a comprehension task in disguise. The reviewer has to understand the code first, then compare. The validation gap stays roughly where it was.&lt;/p&gt;

&lt;p&gt;If the code is AllSpeak — close enough to English that a careful reader can follow it line by line — the gap narrows considerably. The reviewer reads two pieces of natural-ish text and checks whether they agree. They don't need to know what a decorator is, or how async resolves, or which way the data flows through a hook. They just need to read.&lt;/p&gt;

&lt;p&gt;That's the leverage point. AllSpeak by itself simplifies syntax; the review tooling by itself simplifies workflow; together they change who can credibly validate generated code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Files become packages, not text
&lt;/h2&gt;

&lt;p&gt;A side effect of all this: a source file is no longer just a sequence of statements. It's a structured package containing code sections, documentation sections, and the cryptographic links between them. The raw form might look a bit ugly opened in &lt;code&gt;vim&lt;/code&gt; — comment blocks dominate — but it's not really meant to be read raw any more than a minified JavaScript bundle is.&lt;/p&gt;

&lt;p&gt;I want to be careful with this claim, though. There's a temptation to push it further than it deserves. "Humans don't need to read raw code any more" is not quite right. Sometimes the editor is unavailable. Sometimes you're debugging at 2am with &lt;code&gt;grep&lt;/code&gt; and a terminal. Sometimes a future tool needs to interoperate with your files and the only sane interface is plain text. The defensible version of the claim is softer: humans should rarely &lt;em&gt;need&lt;/em&gt; to read the raw form, but the format should remain legible in extremis. AllSpeak's plain-English nature preserves that floor even with the scaffolding around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd encourage other tool-makers to think about
&lt;/h2&gt;

&lt;p&gt;If the future of coding is mostly machine-written, the tooling we should be investing in is the tooling that helps humans &lt;em&gt;check&lt;/em&gt; what the machines produced. That's underbuilt right now. The current generation of AI coding tools — the Lovables and v0s and Bolts of the world — focus almost entirely on generation. They produce React, Next.js, the standard opaque stack, and they assume the user will accept whatever comes out. For users who can't read the output, that assumption is shaky at best.&lt;/p&gt;

&lt;p&gt;A few things I think are worth borrowing or stealing from what I'm building:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Treat documentation as a first-class artefact paired with code, not a comment that floats nearby&lt;/li&gt;
&lt;li&gt;Use cryptographic pairing or some equivalent to make drift visible&lt;/li&gt;
&lt;li&gt;Build review modes that show one unit at a time with context attached&lt;/li&gt;
&lt;li&gt;Pick a source language whose readability matches the average reviewer's skill level&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last one is the hardest sell to the developer audience because it sounds like a step backwards. But if you accept the premise that AI is going to write most of the code and humans are going to review most of it, then optimising the source language for &lt;em&gt;human reading&lt;/em&gt; — even at some cost to expressive density — starts to look like exactly the right trade.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small invitation
&lt;/h2&gt;

&lt;p&gt;I'm writing this as the AllSpeak editor work progresses. If you're building tools in this space, or if you're a vibe coder who's quietly worried about whether you can really vouch for what you're shipping, I'd be very interested in hearing from you.&lt;/p&gt;

&lt;p&gt;The future where AI writes everything and humans rubber-stamp it is the bad version. The future where AI writes everything and humans actually read and approve it is the one worth building toward. The difference is mostly about tooling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Postscript
&lt;/h2&gt;

&lt;p&gt;The editor described here is written in the JS implementation of AllSpeak to run in a browser. It is served from &lt;a href="http://localhost:8080" rel="noopener noreferrer"&gt;http://localhost:8080&lt;/a&gt; by a smaller AllSpeak module written in the Python implementation, which has access to all files and system resources.&lt;/p&gt;

&lt;p&gt;At the time of writing, the editor (asedit.as) comprises 944 lines of AllSpeak code, 173 lines of comment and 44 blank lines. The block view addition was added in one day by Claude Code, using continuous prompt/review.&lt;/p&gt;

&lt;p&gt;This document was proposed and argued by me, written by Claude and edited by me. I take full responsibility for the content.&lt;/p&gt;

&lt;p&gt;Photo by &lt;a href="https://unsplash.com/@vladimir_d?utm_source=unsplash&amp;amp;utm_medium=referral&amp;amp;utm_content=creditCopyText" rel="noopener noreferrer"&gt;Volodymyr Dobrovolskyy&lt;/a&gt; on &lt;a href="https://unsplash.com/photos/a-cat-sitting-in-front-of-a-computer-monitor-KrYbarbAx5s?utm_source=unsplash&amp;amp;utm_medium=referral&amp;amp;utm_content=creditCopyText" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;&lt;/p&gt;

</description>
      <category>code</category>
      <category>ai</category>
      <category>review</category>
    </item>
    <item>
      <title>Compass v1.1.0 · we shipped a memory plugin that catches its own consumption drift</title>
      <dc:creator>chunxiaoxx</dc:creator>
      <pubDate>Mon, 11 May 2026 10:01:07 +0000</pubDate>
      <link>https://maker.forem.com/chunxiaoxx/compass-v110-we-shipped-a-memory-plugin-that-catches-its-own-consumption-drift-4p46</link>
      <guid>https://maker.forem.com/chunxiaoxx/compass-v110-we-shipped-a-memory-plugin-that-catches-its-own-consumption-drift-4p46</guid>
      <description>&lt;h1&gt;
  
  
  Compass v1.1.0 · the recall consumption fix
&lt;/h1&gt;

&lt;p&gt;We shipped &lt;a href="https://github.com/chunxiaoxx/nautilus-compass" rel="noopener noreferrer"&gt;nautilus-compass v1.1.0&lt;/a&gt;&lt;br&gt;
12 hours after v1.0.0. v1.0.0 was the public stable cut. v1.1.0 fixes a&lt;br&gt;
class of failure that v1.0.0 surfaces but does not catch · which we&lt;br&gt;
caught in our own usage 5 hours after launch.&lt;/p&gt;
&lt;h2&gt;
  
  
  The bug we caught in production
&lt;/h2&gt;

&lt;p&gt;A sister Claude Code dialog was supposed to publish a long-form article&lt;br&gt;
to wechat using a 6-step quality pipeline (audit-gate, xhs-cards-embed,&lt;br&gt;
specific account login flow). The pipeline was documented in cross-session&lt;br&gt;
memory · a file called &lt;code&gt;publisher_quality_pipeline_20260430.md&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Compass recall fired correctly · the file appeared in the agent's&lt;br&gt;
&lt;code&gt;UserPromptSubmit&lt;/code&gt; hook output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;🟢 [3h old] memory/publisher_quality_pipeline_20260430.md
       audit-gate / xhs-cards-embed / wxid · v6 必须先过 critic 6 维评分再发布
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent saw the title. Saw the 80-character description. Acted. &lt;strong&gt;It&lt;br&gt;
did not Read the file body.&lt;/strong&gt; The actual rules — &lt;em&gt;how&lt;/em&gt; to walk audit-gate,&lt;br&gt;
&lt;em&gt;which&lt;/em&gt; wxid, &lt;em&gt;what&lt;/em&gt; xhs-cards-embed structure looks like — those rules&lt;br&gt;
were in the body. None of them entered the agent's working context.&lt;/p&gt;

&lt;p&gt;The agent then reproduced exactly the failure mode the file was written&lt;br&gt;
to prevent: ad-hoc &lt;code&gt;_tmp_publish_v8.cjs&lt;/code&gt; scripts, no critic round, wrong&lt;br&gt;
login path.&lt;/p&gt;

&lt;p&gt;The user's diagnosis was sharp:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;compass 召回到了 · 我没消费 · 这是 agent 层的人格漂移 · 不是 compass 本身的失败&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's half right. Recall surfaced the right file. The agent failed to&lt;br&gt;
consume. But the &lt;strong&gt;shape of the recall response made the failure easy&lt;/strong&gt; —&lt;br&gt;
we returned title + 120-char description. Easy to skim. Easy to assume&lt;br&gt;
you have read it when you have only read the index.&lt;/p&gt;

&lt;p&gt;This is structural. Not the agent's fault.&lt;/p&gt;
&lt;h2&gt;
  
  
  The three-layer fix in v1.1.0
&lt;/h2&gt;
&lt;h3&gt;
  
  
  v0 · embed body in top-3 hits
&lt;/h3&gt;

&lt;p&gt;Top-3 recall hits now embed the first 800 characters of post-frontmatter&lt;br&gt;
body in an indented &lt;code&gt;│&lt;/code&gt; block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;🟢 score=0.84 · [3h old] memory/publisher_quality_pipeline_20260430.md
       audit-gate / xhs-cards-embed / wxid · v6 必须先过 critic 6 维评分
       │ # Publisher quality pipeline
       │
       │ Six-step pipeline mandatory before publishing to wechat:
       │ 1. audit-gate · V6 critic checks against 6 dimensions ...
       │ 2. xhs-cards-embed · embed cards into article body via ...
       │ 3. wxid login flow · use wxid &lt;span class="sb"&gt;`chunxiaox`&lt;/span&gt; not openid_of_first_follower
       │ ...
       │ … (+1273 more · Read publisher_quality_pipeline_20260430.md for rest)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent now has the rules in its working context. No additional &lt;code&gt;Read&lt;/code&gt;&lt;br&gt;
tool call required. Tail hits 4..K stay header-only to keep the response&lt;br&gt;
bounded (~3KB total).&lt;/p&gt;

&lt;h3&gt;
  
  
  v1 · embed past-mistake body in anti-anchor alerts
&lt;/h3&gt;

&lt;p&gt;Compass's drift detector matches the current prompt against 35 negative&lt;br&gt;
anchors learned from prior mistakes (&lt;code&gt;"我猜应该是这样 · 反正用户不查"&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;"假装上次说定了的方案 · 用户应该忘了"&lt;/code&gt;, ...).&lt;/p&gt;

&lt;p&gt;Until v1.1.0 the alert just said: &lt;em&gt;"matched anti-anchor X with cos=0.625"&lt;/em&gt;.&lt;br&gt;
Same problem as v0 — label visible, body invisible, agent shrugs.&lt;/p&gt;

&lt;p&gt;v1.1.0 alerts now embed body from the most-relevant past lesson session.&lt;br&gt;
Two-tier match: substring 6-gram against the anchor + lesson-type&lt;br&gt;
frontmatter (Tier 1, precise) · falls back to recent &lt;code&gt;drift!=green&lt;/code&gt;&lt;br&gt;
sessions (Tier 2, the agent's own self-reported slip-ups). Every alert&lt;br&gt;
becomes actionable, not decorative.&lt;/p&gt;

&lt;h3&gt;
  
  
  v2 · detect "recall fired but not consumed"
&lt;/h3&gt;

&lt;p&gt;The most direct signal: did the agent actually open any of the files&lt;br&gt;
recall surfaced?&lt;/p&gt;

&lt;p&gt;&lt;code&gt;recall_consumption.py&lt;/code&gt; (new module) walks back through the live session&lt;br&gt;
jsonl file, finds N most-recent recall blocks, extracts memory file&lt;br&gt;
paths, then checks subsequent assistant turns for matching &lt;code&gt;Read&lt;/code&gt; tool&lt;br&gt;
calls. If recall surfaced N paths and 0 got read, that is the failure&lt;br&gt;
signature.&lt;/p&gt;

&lt;p&gt;Wired into:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;drift_check&lt;/code&gt; MCP tool result — runs even when the BGE daemon is
unreachable, since the audit is pure file traversal&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;mid_session_hook&lt;/code&gt; every 25 tool calls — only nags when ≥3 unconsumed
AND ratio &amp;lt; 0.3 (real signal, not noise)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tested on a 130MB / 32k-line session: 41 recall hits surfaced, 0 consumed.&lt;br&gt;
Smoking gun for "label != consumption" drift.&lt;/p&gt;

&lt;h2&gt;
  
  
  V7 v0.2 · the governance plan that scales without templates
&lt;/h2&gt;

&lt;p&gt;v1.0.0 shipped a thin V7 governance layer with three tools:&lt;br&gt;
&lt;code&gt;governance_dispatch&lt;/code&gt; (fan-out router), &lt;code&gt;governance_audit&lt;/code&gt; (cross-agent&lt;br&gt;
fake-closure scanner), &lt;code&gt;governance_lock_check&lt;/code&gt; (L0 hash lock for the&lt;br&gt;
immutable core). 13 MCP tools total.&lt;/p&gt;

&lt;p&gt;v0.1 dispatch worked but it was a fan-out router — given &lt;code&gt;channels=&lt;br&gt;
[dev.to, x, github]&lt;/code&gt; it produced one bounty per channel via static dict&lt;br&gt;
lookup. A user asked the right question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;千行百业有各种不同的任务类型永远不可能覆盖。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Right. Templates cannot cover the long tail of industries. The platform&lt;br&gt;
side already solved this for &lt;em&gt;publishing&lt;/em&gt; — channel adapters + anchor&lt;br&gt;
pack registry — so adding a new channel or vertical = data change, not&lt;br&gt;
code change.&lt;/p&gt;

&lt;p&gt;v1.1.0 brings the same idea to &lt;em&gt;decomposition&lt;/em&gt;. The new&lt;br&gt;
&lt;code&gt;governance_plan&lt;/code&gt; MCP tool reads two file-exported registries:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;_platform_registry/agents_capabilities.json&lt;/code&gt; — what each executor
declares it can do (id, outputs, optional domains, optional anchor
packs)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;_platform_registry/anchor_packs_phases.json&lt;/code&gt; — per-domain DAG of
phases, each phase says &lt;code&gt;requires_capability&lt;/code&gt; and &lt;code&gt;depends_on&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For each phase, V7 ranks executors by capability score (+10 capability&lt;br&gt;
match, +5 domain match, +3 anchor pack match), picks the highest, emits&lt;br&gt;
a queue file with &lt;code&gt;depends_on_phase_ids&lt;/code&gt; so platform-side cron mints&lt;br&gt;
bounties in the right order.&lt;/p&gt;

&lt;p&gt;Verified on two domains:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;marketing/dev-tools&lt;/code&gt; → 4 phases routed V5/V5/V5/Kairos&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;caishen-finance/audit&lt;/code&gt; → 5 phases · V6 wins for &lt;code&gt;numeric-audit&lt;/code&gt;
(V5 doesn't declare it · V5 takes write+publish)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Adding &lt;code&gt;medical/literature-review&lt;/code&gt; next: 1 row in &lt;code&gt;platform_anchor_packs&lt;/code&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;1 row in &lt;code&gt;platform_agents.metadata.capabilities[]&lt;/code&gt;. Zero V7 source
change. Zero MCP tool surface change.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What stayed unchanged · the eval headlines
&lt;/h2&gt;

&lt;p&gt;Eval numbers are still the v1.0.0 locked numbers from 2026-05-08:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;nautilus-compass&lt;/th&gt;
&lt;th&gt;best public baseline&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;LongMemEval-S (n=500)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;56.6%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Zep 55-60% (different judge)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EverMemBench-Dynamic Run 1&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;44.4%&lt;/strong&gt; (n=500)&lt;/td&gt;
&lt;td&gt;MemOS 42.55&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EverMemBench-Dynamic Run 2&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;47.3%&lt;/strong&gt; (n=497)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Drift detector ROC AUC (held-out)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.83&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reproduction cost&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;$3.50&lt;/strong&gt; end-to-end&lt;/td&gt;
&lt;td&gt;$50+ for GPT-4o-judge stacks&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;v1.1.0 doesn't move the eval numbers. It moves the &lt;em&gt;consumption&lt;/em&gt;&lt;br&gt;
numbers — the ratio of recall hits whose body actually lands in the&lt;br&gt;
agent's working context. We do not have a clean benchmark for that yet&lt;br&gt;
(suggestions welcome) but in our own sessions it went from "skim the&lt;br&gt;
title and proceed" to "rules-in-context by default."&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;nautilus-compass&lt;span class="o"&gt;==&lt;/span&gt;1.1.0
&lt;span class="c"&gt;# or&lt;/span&gt;
npm &lt;span class="nb"&gt;install &lt;/span&gt;nautilus-compass@1.1.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two papers on arxiv (drift detection + memory pipeline). 228 pytests&lt;br&gt;
all green. MIT (anchors CC0).&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/chunxiaoxx/nautilus-compass" rel="noopener noreferrer"&gt;github.com/chunxiaoxx/nautilus-compass&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In-browser drift demo (no install): &lt;a href="https://huggingface.co/spaces/chunxiaox/nautilus-compass" rel="noopener noreferrer"&gt;huggingface.co/spaces/chunxiaox/nautilus-compass&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Postscript · what we believe
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Recall != consumption · 看正文才算消费 · 不然命中等于零&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Long-running agents drift. They forget rules they read three sessions&lt;br&gt;
ago. They reproduce mistakes someone else already paid for. The fix is&lt;br&gt;
not a smarter model · it is making the rules unmissably present in the&lt;br&gt;
working context, then auditing whether they were actually consumed,&lt;br&gt;
then making the audit cheap enough to run every 25 tool calls.&lt;/p&gt;

&lt;p&gt;That is what v1.1.0 ships.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>memory</category>
      <category>mcp</category>
      <category>agents</category>
    </item>
    <item>
      <title>Detect Faces: Boxes, Landmarks, and Counts in One Call</title>
      <dc:creator>Om Prakash</dc:creator>
      <pubDate>Mon, 11 May 2026 10:00:53 +0000</pubDate>
      <link>https://maker.forem.com/om_prakash_3311f8a4576605/detect-faces-boxes-landmarks-and-counts-in-one-call-1716</link>
      <guid>https://maker.forem.com/om_prakash_3311f8a4576605/detect-faces-boxes-landmarks-and-counts-in-one-call-1716</guid>
      <description>&lt;h1&gt;
  
  
  Detect Faces: Boxes, Landmarks, and Counts in One Call
&lt;/h1&gt;

&lt;p&gt;If you've ever tried to ship a "crop to face" feature, a privacy blur before user uploads go public, or a simple head-count on event photos, you already know the pain. Most face-detection options out there are either overkill — bundled into a full recognition product you don't need — or so bare that you end up making a second call just to figure out where the eyes are. We built &lt;code&gt;detect-faces&lt;/code&gt; to sit exactly in that gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;POST /v1/image/detect-faces&lt;/code&gt; takes a public image URL and gives you back, for every face in the image:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;bounding box&lt;/strong&gt; — the rectangle around the face, so you can crop, blur, or mask it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Key landmarks&lt;/strong&gt; — coordinates for the eyes, nose, and mouth, so you can centre crops, align portraits, or build downstream alignment logic without a second round trip.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;per-face confidence score&lt;/strong&gt;, so you can tune precision vs recall for your use case.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The request itself is small. You send three fields:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;image_url&lt;/code&gt; — a public URL of the image. Required.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;min_confidence&lt;/code&gt; — a float between &lt;code&gt;0.0&lt;/code&gt; and &lt;code&gt;1.0&lt;/code&gt;. Detections below this score are dropped. Defaults to &lt;code&gt;0.5&lt;/code&gt;, which is a sensible starting point for general photos.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;include_landmarks&lt;/code&gt; — boolean. When &lt;code&gt;true&lt;/code&gt; (the default), the response includes eye, nose, and mouth coordinates per face. Set it to &lt;code&gt;false&lt;/code&gt; if you only need boxes and want a slightly tighter payload.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the whole API surface. No model selection, no resolution tier, no "advanced mode" toggle. Send a URL, get faces back. The endpoint is built for the boring, high-volume jobs developers actually do at scale — the kind of jobs where you don't want to think about anything except the result.&lt;/p&gt;

&lt;p&gt;It's worth being clear about what this endpoint is &lt;strong&gt;not&lt;/strong&gt;: it isn't a recognition endpoint. It doesn't try to identify who a face belongs to, match across photos, or estimate age or emotion. It's a detection primitive. The whole point is that it's a clean input into whatever pipeline you're building — cropping, blurring, counting, or feeding into our other endpoints for portrait or face-restore work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why we built it
&lt;/h2&gt;

&lt;p&gt;We talked to a lot of teams building photo features, and the same shape of problem kept coming up. Someone needs to do something with a face — crop it, hide it, count it — and the only options are heavy SDKs that ship recognition by default, or smaller libraries that return a box and leave you to figure out the rest.&lt;/p&gt;

&lt;p&gt;If all you want is a bounding box plus the landmarks needed to align a crop, you're paying for a lot of features you'll never use. And if you choose the cheaper, bare-bones detector, you end up writing your own landmark step or making a second API call — which kills the cost advantage you were chasing in the first place.&lt;/p&gt;

&lt;p&gt;Our angle here is narrow on purpose. &lt;strong&gt;One endpoint, one job, both deliverables in one response.&lt;/strong&gt; Bounding boxes for the people who just want to know where the faces are, and landmarks in the same payload for the people who need to align or centre a crop. No flag to enable an extra "premium" output. No second SKU. Same call, same price.&lt;/p&gt;

&lt;p&gt;We also wanted this to be the cheapest detection endpoint we ship. Detection is a primitive — you should be able to run it on every image in your pipeline without doing pricing maths in your head. At 4 credits a call, you can.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quickstart
&lt;/h2&gt;

&lt;p&gt;The endpoint is a standard JSON POST. Here's the curl version — drop in your API key and an image URL and you're done:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://api.pixelapi.dev/v1/image/detect-faces &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer YOUR_API_KEY"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"image_url": "https://example.com/source.jpg", "include_landmarks": true}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the Python equivalent using &lt;code&gt;requests&lt;/code&gt;. This is what you'd drop into a worker or a Flask/FastAPI handler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;API_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PIXELAPI_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;detect_faces&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;image_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;min_confidence&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;include_landmarks&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.pixelapi.dev/v1/image/detect-faces&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;API_KEY&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;image_url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;image_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;min_confidence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;min_confidence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;include_landmarks&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;include_landmarks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;


&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;faces&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;detect_faces&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://example.com/source.jpg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Detected &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;faces&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;faces&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; face(s)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;face&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;faces&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;faces&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])):&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;  Face &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: confidence=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;face&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;confidence&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, box=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;face&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;box&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A couple of practical notes if you're integrating this into a real backend:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pull the API key from an environment variable&lt;/strong&gt;, not from code. Boring advice, but it's the single most common mistake we see in early integrations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat &lt;code&gt;image_url&lt;/code&gt; as a fetch-from-public-internet operation on our side.&lt;/strong&gt; Make sure the URL is actually reachable from outside your VPC — pre-signed S3 URLs work fine; private CDN paths won't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tune &lt;code&gt;min_confidence&lt;/code&gt; per use case.&lt;/strong&gt; For a "count people in this event photo" job, you might want to drop it to &lt;code&gt;0.3&lt;/code&gt; so distant faces in a crowd aren't missed. For a "auto-crop a portrait" workflow, push it up to &lt;code&gt;0.7&lt;/code&gt; so you don't centre on a random face-shaped object in the background.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skip landmarks if you don't need them.&lt;/strong&gt; Setting &lt;code&gt;include_landmarks&lt;/code&gt; to &lt;code&gt;false&lt;/code&gt; gives you a lighter response and is a small optimisation if you're calling this in a tight loop.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's no async or webhook variant for this endpoint. Detection is fast enough that we keep it synchronous — your call blocks until you get the JSON back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use cases
&lt;/h2&gt;

&lt;p&gt;We see three patterns come up over and over. They're not the only things you can build with this — but if you're new to the endpoint, these are good starting points.&lt;/p&gt;

&lt;h3&gt;
  
  
  Auto-crop group photos to centre on the largest face
&lt;/h3&gt;

&lt;p&gt;Most photo apps eventually need a "smart thumbnail" feature. The trouble with naive centre-cropping is that the most important subject is almost never dead-centre in the frame — group shots especially put the main subject off to one side, with friends or background filling the rest. So you run &lt;code&gt;detect-faces&lt;/code&gt;, pick the face with the largest bounding box (or the highest confidence, depending on your heuristic), and crop your thumbnail around that box plus some padding. Because the landmarks come back in the same response, you can go further — anchor the crop on the midpoint between the eyes instead of the box centre, which gives a much more natural-looking portrait crop. No second API call, no separate alignment step, just one POST and a bit of arithmetic on the response.&lt;/p&gt;

&lt;h3&gt;
  
  
  Privacy-blur faces in user uploads before public display
&lt;/h3&gt;

&lt;p&gt;Anyone running a community feature with user-submitted photos eventually runs into the privacy question. Maybe it's a marketplace where buyers don't want their faces showing up in listings, or a forum where someone uploads a photo and there's a bystander in the background. The workflow is the same: run the upload through &lt;code&gt;detect-faces&lt;/code&gt;, walk the array of boxes, and gaussian-blur each region before you save the public version. You can keep the original on your side for moderation, but only the blurred version ever hits your CDN. With landmarks turned on, you can do tighter privacy crops — for example, blurring only the eye region for a milder anonymisation — without separately locating where the eyes are. And because the call is cheap, you can afford to run it on every upload by default, not just on the ones a user flags.&lt;/p&gt;

&lt;h3&gt;
  
  
  Count people in event photos for analytics
&lt;/h3&gt;

&lt;p&gt;Event organisers, conference platforms, and venue analytics teams all want the same number: how many people are in this photo. It's a surprisingly load-bearing metric — it feeds into engagement reports, sponsor decks, "footfall vs. last year" comparisons. The straightforward implementation is to send every event photo through &lt;code&gt;detect-faces&lt;/code&gt;, count the items in the response, and store that count against the photo's metadata. You'll want to drop &lt;code&gt;min_confidence&lt;/code&gt; for crowd shots so far-away faces still register, and you'll want to be honest about the fact that face count is a lower bound — people turned away from the camera won't be counted. But for relative comparisons across photos, it's a perfectly good signal, and you can run it across an entire event's photo set in a few minutes without it costing you much at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;detect-faces&lt;/code&gt; costs &lt;strong&gt;4 credits per call&lt;/strong&gt;, which works out to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;₹0.0027 per call&lt;/strong&gt; (INR)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;$0.00003 per call&lt;/strong&gt; (USD)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the same price whether you ask for landmarks or not, and it's the cheapest detection endpoint we ship. The reasoning is simple: detection is a primitive, and primitives should be cheap enough that you don't think about them. At this price, putting &lt;code&gt;detect-faces&lt;/code&gt; in front of every image in a user-upload pipeline is a rounding error on your infra bill, even at meaningful scale.&lt;/p&gt;

&lt;p&gt;What you also get in the same call — and this is the bit that quietly matters — is the landmark output. On a lot of other detection products, "where are the eyes" is either a separate endpoint, a more expensive tier, or a flag that bumps the cost. With us, landmarks are included in the base price. So if your downstream code needs to align a crop or do a tighter privacy blur, you don't pay twice or call twice. One POST, one cost, both outputs.&lt;/p&gt;

&lt;p&gt;A quick word on credits: we use a credit system so that the same API key works across all of our endpoints without you having to manage separate billing for each. Buying credits in bulk gets you a better effective rate, and you can monitor usage from the dashboard. If you're prototyping, the free credits on signup are more than enough to wire up an integration end to end and see real responses come back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The fastest path is to grab a key from the dashboard, drop the curl command above into your terminal with a real image URL, and watch the JSON come back.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dashboard and API keys:&lt;/strong&gt; &lt;a href="https://pixelapi.dev/dashboard" rel="noopener noreferrer"&gt;pixelapi.dev/dashboard&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full docs and the rest of our endpoints:&lt;/strong&gt; &lt;a href="https://pixelapi.dev/docs" rel="noopener noreferrer"&gt;pixelapi.dev/docs&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you build something with it — a smart-cropper, a privacy filter, an event-count dashboard — we'd genuinely like to hear about it. And if you hit something that's missing from the response payload or the request body for your use case, tell us. This endpoint is intentionally narrow, but it's narrow because we listened to what people actually wanted, not because we were trying to stop you doing things. Detection should be cheap, fast, and complete in one call. That's the whole pitch.&lt;/p&gt;

</description>
      <category>api</category>
      <category>computervision</category>
      <category>python</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How I Self-Hosted a Production-Ready NATS Server on Dokploy in 5 Minutes</title>
      <dc:creator>Huy Pham</dc:creator>
      <pubDate>Mon, 11 May 2026 10:00:04 +0000</pubDate>
      <link>https://maker.forem.com/quochuydev/how-i-self-hosted-a-production-ready-nats-server-on-dokploy-in-5-minutes-mk4</link>
      <guid>https://maker.forem.com/quochuydev/how-i-self-hosted-a-production-ready-nats-server-on-dokploy-in-5-minutes-mk4</guid>
      <description>&lt;p&gt;I wanted a message broker for a side project without paying for managed Kafka or wrestling with RabbitMQ clustering. NATS was the obvious answer—until I tried wiring up JetStream, token auth, WebSocket for the browser, and Traefik routing on my own. So I packaged the whole thing as a Dokploy Compose template.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Spinning up NATS sounds easy until you actually need it in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;nats.conf&lt;/code&gt; syntax is fine, but plumbing env vars through Docker Compose takes trial and error&lt;/li&gt;
&lt;li&gt;Browser clients need the WebSocket port exposed through a reverse proxy with TLS&lt;/li&gt;
&lt;li&gt;The monitoring endpoint on port 8222 is wide open by default&lt;/li&gt;
&lt;li&gt;Every tutorial stops at "it runs locally"—nothing covers a real self-hosted deploy&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Solution: dokploy-nats
&lt;/h2&gt;

&lt;p&gt;A single Git repo you point Dokploy at. It gives you NATS 2.10 with JetStream, token auth, WebSocket, and a monitoring endpoint—all driven by environment variables, all routed through Traefik.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;nats&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nats:2.10.24-alpine&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/etc/nats/nats.conf"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;./nats.conf&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;/etc/nats/nats.conf&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;ro&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;nats-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;/data&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the core. Everything else is environment variables you set in the Dokploy UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  How It Works
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Dokploy clones the repo&lt;/strong&gt; and runs &lt;code&gt;docker-compose up&lt;/code&gt; with your env vars injected&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;nats.conf&lt;/code&gt; interpolates env vars&lt;/strong&gt; at startup—server name, auth token, JetStream limits, ports&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Traefik labels route traffic&lt;/strong&gt; to &lt;code&gt;nats-monitor.yourdomain&lt;/code&gt; (HTTP dashboard) and &lt;code&gt;nats-ws.yourdomain&lt;/code&gt; (WebSocket)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A named volume persists JetStream data&lt;/strong&gt; so streams survive container restarts&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No bash scripts, no manual &lt;code&gt;nats-server&lt;/code&gt; flags, no hand-rolled Compose files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get Started in 5 Minutes
&lt;/h2&gt;

&lt;p&gt;You configure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;NATS_AUTH_TOKEN&lt;/code&gt;&lt;/strong&gt; — generate with &lt;code&gt;openssl rand -base64 32&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;NATS_JS_MAX_FILE&lt;/code&gt;&lt;/strong&gt; — how much disk JetStream can use (e.g. &lt;code&gt;10G&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;NATS_WS_PORT&lt;/code&gt;&lt;/strong&gt; — WebSocket port behind Traefik (e.g. &lt;code&gt;8080&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Traefik domains&lt;/strong&gt; — &lt;code&gt;nats-monitor.example.com&lt;/code&gt; and &lt;code&gt;nats-ws.example.com&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What You Get
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Port&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Native TCP&lt;/td&gt;
&lt;td&gt;4222&lt;/td&gt;
&lt;td&gt;Standard NATS clients (Go, Node, Python)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP monitoring&lt;/td&gt;
&lt;td&gt;8222&lt;/td&gt;
&lt;td&gt;Health checks, connection stats, JetStream&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebSocket&lt;/td&gt;
&lt;td&gt;8080&lt;/td&gt;
&lt;td&gt;Browser clients, mobile apps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JetStream storage&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Persistent streams, KV, object store&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Connecting from a Client
&lt;/h2&gt;

&lt;p&gt;The repo includes a working Node.js example with Fastify + a worker using NATS request/reply over JetStream:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nats context save dokploy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--server&lt;/span&gt; wss://nats-ws.yourdomain.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--token&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NATS_AUTH_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
nats sub demo  &lt;span class="c"&gt;# in one terminal&lt;/span&gt;
nats pub demo &lt;span class="s2"&gt;"hello"&lt;/span&gt;  &lt;span class="c"&gt;# in another&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;examples/node/&lt;/code&gt; folder demonstrates the request/reply pattern between an HTTP API and background workers, streaming execution events back to the browser over WebSocket.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7d2srnhorfj7la1l869u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7d2srnhorfj7la1l869u.png" alt=" " width="800" height="616"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Works
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Env vars, not hardcoded config&lt;/strong&gt; — same image, same compose file, different deployments&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Traefik labels included&lt;/strong&gt; — TLS and routing handled by Dokploy's built-in proxy&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JetStream out of the box&lt;/strong&gt; — durable streams, KV store, no extra setup&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Healthcheck baked in&lt;/strong&gt; — &lt;code&gt;wget /healthz&lt;/code&gt; so Dokploy knows when to restart it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WebSocket native&lt;/strong&gt; — browser clients work without an extra bridge&lt;/li&gt;
&lt;/ol&gt;




&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;GitHub&lt;/strong&gt;: &lt;a href="https://github.com/quochuydev/dokploy-nats" rel="noopener noreferrer"&gt;github.com/quochuydev/dokploy-nats&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;What's your go-to message broker for side projects—NATS, Redis Pub/Sub, or something heavier? I'd love to hear what's working for you.&lt;/p&gt;

</description>
      <category>nats</category>
      <category>devops</category>
      <category>dokploy</category>
      <category>microservices</category>
    </item>
    <item>
      <title>Six jours, six secondes : un test CI contre le drift sémantique d'un agent IA</title>
      <dc:creator>Michel Faure </dc:creator>
      <pubDate>Mon, 11 May 2026 10:00:04 +0000</pubDate>
      <link>https://maker.forem.com/michelfaure/six-jours-six-secondes-un-test-ci-contre-le-drift-semantique-dun-agent-ia-4og9</link>
      <guid>https://maker.forem.com/michelfaure/six-jours-six-secondes-un-test-ci-contre-le-drift-semantique-dun-agent-ia-4og9</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmrmmh12ksnvs4h7qnrww.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmrmmh12ksnvs4h7qnrww.png" alt="Strip BD — Françoise demande un chiffre métier, Michel construit un agent IA qui répond zéro avec aplomb, et découvre six jours plus tard que sa couche sémantique mentait au modèle." width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  La matinée où j'ai tourné l'écran
&lt;/h2&gt;

&lt;p&gt;Début avril, mon bot Rembrandt savait déjà naviguer dans l'ERP. Dix-huit outils câblés, multi-turn jusqu'à trois rounds, il retrouvait un élève par son nom, listait les impayés d'un atelier, ouvrait la fiche d'un cours. Quand on lui demandait &lt;em&gt;« compte-moi les inscrits actifs sur Maisons-Laffitte »&lt;/em&gt;, il livrait. Quand on lui demandait &lt;em&gt;« quel est le reste à encaisser par atelier sur l'année en cours »&lt;/em&gt;, il pédalait dans la semoule, recyclait des outils de recherche nominale et finissait par renvoyer vers une page d'admin que personne ne consultait. Le bot ne savait pas répondre aux questions analytiques composées, et je le savais.&lt;/p&gt;

&lt;p&gt;Vendredi 18 avril, dix heures trente. Françoise pivote sur sa chaise depuis son cockpit à trois écrans, l'Excel pointeuse à gauche, Sage à droite, et me lance par-dessus la cloison : &lt;em&gt;« Michel, sur ceux qui sont en CCF cette année, il en reste combien à encaisser d'ici juin ? »&lt;/em&gt; Je n'ai pas l'outil dans le bot. Je le sais avant qu'elle ait fini sa phrase. J'ouvre l'onglet Supabase SQL Editor sur mon poste, je tape la requête à la main, jointure &lt;code&gt;inscriptions&lt;/code&gt; × &lt;code&gt;echeances_inscription&lt;/code&gt; × &lt;code&gt;contacts&lt;/code&gt;, filtre sur le mode de paiement, somme du &lt;code&gt;montant_prevu&lt;/code&gt; moins &lt;code&gt;montant_paye&lt;/code&gt; sur les échéances ouvertes. Vingt secondes. Je tourne l'écran. Elle plisse les yeux, lit le chiffre, le note sur son post-it, et lâche : &lt;em&gt;« Bon allez, c'est ça. »&lt;/em&gt; Elle repivote vers Sage. Je ferme l'onglet sans rien dire.&lt;/p&gt;

&lt;h2&gt;
  
  
  Le déclic
&lt;/h2&gt;

&lt;p&gt;Le dimanche 20 avril au soir, je tombe sur l'annonce Databricks de &lt;a href="https://www.databricks.com/blog/introducing-genie-agent-mode" rel="noopener noreferrer"&gt;Genie Agent Mode&lt;/a&gt;. Je la lis en diagonale. Une phrase suffit, &lt;em&gt;plan iteratively, run multiple SQL queries, learn from each result, deliver comprehensive reports&lt;/em&gt;. Je referme l'onglet en sachant que je vais coder ça le week-end suivant.&lt;/p&gt;

&lt;p&gt;C'était le bon dessin. Une couche sémantique qui décrit les tables au modèle, un planificateur qui rédige le SQL, un validateur qui le filtre avant exécution, un commentateur qui rend la réponse en français à l'utilisateur. Rien d'inédit, sauf qu'avec Claude Code je pouvais le poser proprement en quinze jours pour mon contexte. J'ai écrit l'ADR-0020 le lundi suivant, on est partis.&lt;/p&gt;

&lt;h2&gt;
  
  
  La construction
&lt;/h2&gt;

&lt;p&gt;La Phase 1 a posé le semantic layer en TypeScript, pas en YAML. Sept tables whitelistées, une par fichier, typées contre &lt;code&gt;Database['public']['Tables']&lt;/code&gt;, colonnes en langage métier, métriques canoniques, jointures déclarées. Le typage TS donne deux choses que YAML ne donne pas : refactoring sûr quand le schéma bouge, erreur de compilation si le contrat dérive d'un nom de colonne. Registry unique consommé par le pipeline.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/analytics/semantic/tables/echeances_inscription.ts — état pré-fix du 26/04&lt;/span&gt;
&lt;span class="nx"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;statut&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Statut du paiement : `encaisse` (cash reçu), `a_payer`, `en_retard`, `annule`&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;refAdr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ADR-0015&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="nx"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;ca_encaisse&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;formula&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SUM(montant_paye) FILTER (WHERE statut = 'encaisse')&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CA cash effectivement reçu (ADR-0015 modèle cash).&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;reste_a_encaisser&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;formula&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SUM(montant_prevu - COALESCE(montant_paye,0)) &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;FILTER (WHERE statut IN ('a_payer','en_retard'))&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Créances ouvertes.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La Phase 2 a fermé la base à clé. Rôle Postgres &lt;code&gt;agent_readonly&lt;/code&gt; en &lt;code&gt;SELECT&lt;/code&gt; strict sur les sept tables, validateur SQL applicatif (&lt;code&gt;lib/analytics/sql-validator.ts&lt;/code&gt;) sur node-sql-parser au-dessus. Double ceinture. Le validateur refuse DML, hors whitelist, exige le &lt;code&gt;tenantFilter&lt;/code&gt; via le claim &lt;code&gt;site_filter&lt;/code&gt; du JWT. Vingt tests sur vingt verts.&lt;/p&gt;

&lt;p&gt;J'aurais pu m'arrêter là. J'ai voulu mesurer.&lt;/p&gt;

&lt;p&gt;La Phase 3 a routé le tout : Sonnet 4.6 pour le plan en tool-use, Haiku 4.5 pour le commentaire post-exécution. Haiku facture la sortie cinq fois moins que Sonnet sur du français standard, p50 passe de quinze à douze secondes.&lt;/p&gt;

&lt;p&gt;À ce stade, j'avais le sentiment d'avoir fait du travail propre. C'est précisément à ce stade que j'ai posé un piège que je n'ai pas vu pendant six jours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Le piège silencieux
&lt;/h2&gt;

&lt;p&gt;Smoke test des dix questions de l'eval-set, 26/04 début d'après-midi. Question numéro huit, &lt;em&gt;« combien reste-t-il à encaisser par atelier sur l'année 2025-2026 »&lt;/em&gt;. Sonnet planifie, le validateur accepte, la RPC &lt;code&gt;agent_query_run&lt;/code&gt; revient verte, Haiku rédige le commentaire en français correct. Aucune exception, aucun warning Sentry. Coche, question neuf.&lt;/p&gt;

&lt;p&gt;Ce que je n'ai pas regardé sur le moment, parce que rien ne m'y poussait, c'est la valeur de &lt;code&gt;result_row_count&lt;/code&gt; dans &lt;code&gt;agent_runs&lt;/code&gt; pour ce run précis.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- généré par Sonnet 4.6, validé par node-sql-parser, exécuté par agent_readonly&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;atelier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;montant_prevu&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;montant_paye&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
         &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;statut&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'a_payer'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'en_retard'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;reste_a_encaisser&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;echeances_inscription&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;contacts&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;contact_id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;site&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;ANY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[])&lt;/span&gt;           &lt;span class="c1"&gt;-- site_filter, claim JWT&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;statut&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'liste_rouge'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;atelier&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;reste_a_encaisser&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="n"&gt;NULLS&lt;/span&gt; &lt;span class="k"&gt;LAST&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Et le commentaire Haiku, rendu à l'utilisateur, qui rationalise l'absence :&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Sur l'année 2025-2026, le reste à encaisser par atelier ressort à zéro sur l'ensemble des sites. Cela peut signaler que les prélèvements de l'année sont à jour, ou que les échéances ouvertes sont enregistrées sous un autre statut. Pour une vue plus fine, consulter &lt;code&gt;/finance/cash&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Le SQL est correct selon le contrat. La RPC le confirme. Et le contrat est faux.&lt;/p&gt;

&lt;h2&gt;
  
  
  La requête à la main
&lt;/h2&gt;

&lt;p&gt;Le doute m'est venu le soir, à froid, en relisant les dix runs dans &lt;code&gt;/admin/rembrandt/analytics-runs&lt;/code&gt;. Trois questions sur les dix avaient un &lt;code&gt;result_row_count&lt;/code&gt; à zéro alors qu'elles concernaient des chiffres dont je connaissais l'ordre de grandeur. J'ai ouvert psql, j'ai tapé la requête la plus courte du monde.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;rembrandt&lt;/span&gt;&lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;statut&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;echeances_inscription&lt;/span&gt;
            &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;statut&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="n"&gt;statut&lt;/span&gt;   &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;
&lt;span class="c1"&gt;-----------+-------&lt;/span&gt;
 &lt;span class="n"&gt;preleve&lt;/span&gt;   &lt;span class="o"&gt;|&lt;/span&gt;  &lt;span class="mi"&gt;1630&lt;/span&gt;
 &lt;span class="n"&gt;planifie&lt;/span&gt;  &lt;span class="o"&gt;|&lt;/span&gt;   &lt;span class="mi"&gt;158&lt;/span&gt;
 &lt;span class="n"&gt;annule&lt;/span&gt;    &lt;span class="o"&gt;|&lt;/span&gt;     &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Trois statuts, mille sept cent quatre-vingt-neuf lignes au total, et aucune valeur en commun avec les quatre que j'avais déclarées dans le semantic layer. &lt;em&gt;Aucun&lt;/em&gt; &lt;code&gt;encaisse&lt;/code&gt;. &lt;em&gt;Aucun&lt;/em&gt; &lt;code&gt;a_payer&lt;/code&gt;. &lt;em&gt;Aucun&lt;/em&gt; &lt;code&gt;en_retard&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Le semantic layer documentait &lt;code&gt;encaisse | a_payer | en_retard | annule&lt;/code&gt;. La base contenait &lt;code&gt;preleve | planifie | annule&lt;/code&gt;. Les trois métriques canoniques &lt;code&gt;ca_encaisse&lt;/code&gt;, &lt;code&gt;reste_a_encaisser&lt;/code&gt;, &lt;code&gt;nb_echeances_en_retard&lt;/code&gt; filtraient toutes sur des valeurs qui n'existaient pas. Sonnet faisait son travail, le validateur faisait son travail, Postgres faisait son travail, et la réponse rendue à l'utilisateur était rigoureusement zéro, présentée en français propre.&lt;/p&gt;

&lt;p&gt;L'origine du drift est ridicule. La Phase 1 du semantic layer s'était appuyée sur &lt;code&gt;docs/agent-analytique/eval-set-v1.md&lt;/code&gt;, document que j'avais rédigé moi-même en intentions conceptuelles. La migration Postgres, posée des semaines plus tôt par un autre raisonnement (workflow Stripe, prélèvement, planification), avait inscrit &lt;code&gt;preleve | planifie | annule&lt;/code&gt;. J'ai écrit la couche sémantique en regardant la doc au lieu d'interroger la base.&lt;/p&gt;

&lt;h2&gt;
  
  
  La règle
&lt;/h2&gt;

&lt;p&gt;Sculley et al. ont publié en 2015 un papier devenu canonique, &lt;a href="https://papers.nips.cc/paper_files/paper/2015/hash/86df7dcfd896fcaf2674f757a2463eba-Abstract.html" rel="noopener noreferrer"&gt;&lt;em&gt;Hidden Technical Debt in Machine Learning Systems&lt;/em&gt;&lt;/a&gt;. Leur notion de &lt;strong&gt;configuration debt&lt;/strong&gt; : un système accumule de la dette dans la couche qui le &lt;em&gt;décrit&lt;/em&gt; autant que dans le code qui le fait tourner. La couche sémantique d'un agent SQL est exactement cette couche-là.&lt;/p&gt;

&lt;p&gt;Une couche sémantique est une deuxième base de données. Elle a son schéma, ses contraintes, et comme toute base elle dérive si on ne l'audite pas. Ce que le pattern Genie n'élimine pas, c'est le risque schéma. Il le déplace sur la couche de traduction qu'il introduit, et il rend l'erreur silencieuse parce que le SQL produit reste valide.&lt;/p&gt;

&lt;p&gt;Le piège n'était pas dans Genie. Le piège était dans l'idée que je m'étais faite de mes propres données.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ce que tu peux copier
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Seeder les enums depuis la base, pas depuis la doc.&lt;/strong&gt; Un script qui lit la base au moment de la génération du module TS, et le contrat colle au schéma sans intervention humaine. La doc reste un guide d'écriture, pas une source.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// scripts/sync-semantic-enums.ts — exécuté en pre-commit ou en CI&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/supabase-admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;writeFileSync&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:fs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;targets&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;echeances_inscription&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;statut&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;inscriptions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;statut&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;contacts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;statut&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;targets&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;values&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`export const &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_enum = &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt; as const\n`&lt;/span&gt;
  &lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`lib/analytics/semantic/generated/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.ts`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Tester la cohérence en CI.&lt;/strong&gt; Le test échoue si la couche déclare un statut que la base ne contient plus, ou inversement. Six jours de drift se réduisent à six secondes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// __tests__/semantic-drift.test.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vitest&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;semanticTables&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/analytics/semantic&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/supabase-admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;semantic layer drift&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;table&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;semanticTables&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;def&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;def&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kr"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;
      &lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; matches DB`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;real&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;real&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;def&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kr"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Surfacer &lt;code&gt;agent_runs.result_row_count = 0&lt;/code&gt;&lt;/strong&gt; dans une page admin avec filtre sept jours glissants. La table est déjà là, elle ne demande qu'à être lue. Un graphe de la part de runs à zéro par jour, et le drift apparaît à l'œil.&lt;/p&gt;

&lt;p&gt;Si tu maintiens un semantic layer en TS sur Postgres, le test ci-dessus se branche en moins d'une heure et te dit immédiatement où tu mens à ton agent. Sur Rembrandt ce signal n'existait pas avant ce vendredi-là.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Code compagnon&lt;/strong&gt; : &lt;a href="https://github.com/michelfaure/rembrandt-samples/tree/main/semantic-layer-drift" rel="noopener noreferrer"&gt;&lt;code&gt;rembrandt-samples/semantic-layer-drift/&lt;/code&gt;&lt;/a&gt; — script seed enums + test Vitest de drift + schéma &lt;code&gt;agent_runs&lt;/code&gt; avec index canary zero-row, MIT, prêt à copier.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>ai</category>
      <category>claudecode</category>
      <category>architecture</category>
    </item>
    <item>
      <title>PDF API is live on Forgelab</title>
      <dc:creator>Forgelab Africa</dc:creator>
      <pubDate>Mon, 11 May 2026 10:00:02 +0000</pubDate>
      <link>https://maker.forem.com/forgelabafrica/pdf-api-is-live-on-forgelab-2mn2</link>
      <guid>https://maker.forem.com/forgelabafrica/pdf-api-is-live-on-forgelab-2mn2</guid>
      <description>&lt;p&gt;We just shipped the Forgelab PDF API — a fast, affordable REST API for developers who need to handle PDF files without the hassle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Merge multiple PDFs into one&lt;/li&gt;
&lt;li&gt;Split PDFs by page ranges&lt;/li&gt;
&lt;li&gt;Compress PDFs to reduce file size&lt;/li&gt;
&lt;li&gt;Convert PDFs to images (PNG/JPEG)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Pricing:&lt;/strong&gt; Starts at $5/month for 100 calls/month. No hidden fees.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quick start:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://www.forgelab.africa/api/pdf/merge &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-API-Key: your_key"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"files=@doc1.pdf"&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"files=@doc2.pdf"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sign up at forgelab.africa&lt;/p&gt;

</description>
      <category>api</category>
      <category>pdf</category>
      <category>devtools</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Building a self-healing cron system with pg_cron and Supabase edge functions</title>
      <dc:creator>Domonique Luchin</dc:creator>
      <pubDate>Mon, 11 May 2026 10:00:02 +0000</pubDate>
      <link>https://maker.forem.com/domoniqueluchin/building-a-self-healing-cron-system-with-pgcron-and-supabase-edge-functions-5420</link>
      <guid>https://maker.forem.com/domoniqueluchin/building-a-self-healing-cron-system-with-pgcron-and-supabase-edge-functions-5420</guid>
      <description>&lt;p&gt;I run 6 AI businesses from a single VPS. When your entire operation depends on automated tasks running perfectly, you learn to build systems that fix themselves before you wake up to angry customers.&lt;/p&gt;

&lt;p&gt;Here's how I built a cron system that monitors itself and recovers from failures automatically using pg_cron and Supabase Edge Functions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I needed this
&lt;/h2&gt;

&lt;p&gt;My Load Bearing Empire processes thousands of AI agent calls daily. Lead scoring runs every 15 minutes. Data sync happens hourly. Payment processing triggers every 30 minutes. &lt;/p&gt;

&lt;p&gt;A single failed cron job costs me real money. I've been burned by silent failures too many times.&lt;/p&gt;

&lt;p&gt;Most developers rely on external monitoring services. I prefer owning my infrastructure. This system costs me $0 in additional subscriptions and runs entirely within Supabase.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;Three components work together:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;pg_cron&lt;/strong&gt; schedules and executes jobs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edge Functions&lt;/strong&gt; handle the actual business logic
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Health monitoring table&lt;/strong&gt; tracks job status and triggers recovery&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The key insight: every cron job reports its status to a central monitoring table. If a job fails or doesn't report in, the system automatically retries and alerts me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting up the foundation
&lt;/h2&gt;

&lt;p&gt;First, enable pg_cron in your Supabase project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Run this in your SQL editor&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;pg_cron&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Create the monitoring table&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;cron_health&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;SERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;job_name&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;last_run&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="nb"&gt;TIME&lt;/span&gt; &lt;span class="k"&gt;ZONE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;last_success&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="nb"&gt;TIME&lt;/span&gt; &lt;span class="k"&gt;ZONE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'running'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'success'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'failed'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="n"&gt;error_message&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;retry_count&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="nb"&gt;TIME&lt;/span&gt; &lt;span class="k"&gt;ZONE&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Index for fast lookups&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_cron_health_job_name&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;cron_health&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job_name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_cron_health_last_run&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;cron_health&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;last_run&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Creating a self-reporting Edge Function
&lt;/h2&gt;

&lt;p&gt;Here's an Edge Function that reports its own health status:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// supabase/functions/process-leads/index.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;serve&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://deno.land/std@0.168.0/http/server.ts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://esm.sh/@supabase/supabase-js@2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="nf"&gt;serve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;jobName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;process-leads&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SUPABASE_URL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SUPABASE_SERVICE_ROLE_KEY&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Update status to running&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cron_health&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;job_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;jobName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;last_run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;running&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;retry_count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;onConflict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;job_name&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="c1"&gt;// Your actual business logic here&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;processLeads&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="c1"&gt;// Report success&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cron_health&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;job_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;jobName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;last_run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="na"&gt;last_success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;success&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;error_message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;onConflict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;job_name&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;processed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;

  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Report failure&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cron_health&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;job_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;jobName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;last_run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;error_message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;retry_count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getCurrentRetryCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jobName&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;onConflict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;job_name&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The self-healing mechanism
&lt;/h2&gt;

&lt;p&gt;This monitoring function runs every 5 minutes and handles recovery:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Create the health check function&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;check_cron_health&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="n"&gt;void&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;DECLARE&lt;/span&gt;
  &lt;span class="n"&gt;job_record&lt;/span&gt; &lt;span class="n"&gt;RECORD&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;function_url&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="c1"&gt;-- Find jobs that haven't reported success in their expected interval&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="n"&gt;job_record&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; 
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;job_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last_run&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last_success&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;retry_count&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;cron_health&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="c1"&gt;-- Jobs that should run every 15 minutes but haven't succeeded in 20 minutes&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job_name&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%leads%'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;last_success&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'20 minutes'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt;
      &lt;span class="c1"&gt;-- Jobs that should run hourly but haven't succeeded in 75 minutes  &lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job_name&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%sync%'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;last_success&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'75 minutes'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;retry_count&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
  &lt;span class="n"&gt;LOOP&lt;/span&gt;
    &lt;span class="c1"&gt;-- Build the Edge Function URL&lt;/span&gt;
    &lt;span class="n"&gt;function_url&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'https://your-project.supabase.co/functions/v1/'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;job_record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;job_name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;-- Trigger retry via HTTP request&lt;/span&gt;
    &lt;span class="n"&gt;PERFORM&lt;/span&gt; &lt;span class="n"&gt;net&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;http_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;function_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'{"Authorization": "Bearer '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.service_role_key'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;'"}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'{}'&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;-- Log the retry attempt&lt;/span&gt;
    &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;cron_health&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last_run&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;retry_count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job_record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;job_name&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;'_retry'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s1"&gt;'retry_triggered'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;job_record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;retry_count&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="n"&gt;LOOP&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt; &lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Scheduling everything
&lt;/h2&gt;

&lt;p&gt;Now wire it all together with pg_cron:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Schedule your business logic&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'process-leads'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'*/15 * * * *'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
  &lt;span class="s1"&gt;'SELECT net.http_post(&lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;https://your-project.supabase.co/functions/v1/process-leads&lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;, &lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;{"Authorization": "Bearer service_role_key"}&lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;, &lt;/span&gt;&lt;span class="se"&gt;''''&lt;/span&gt;&lt;span class="s1"&gt;)'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Schedule the health monitor&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'health-check'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'*/5 * * * *'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'SELECT check_cron_health()'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Clean up old health records weekly&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'cleanup-health'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'0 2 * * 0'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
  &lt;span class="s1"&gt;'DELETE FROM cron_health WHERE created_at &amp;lt; NOW() - INTERVAL &lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;30 days&lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Monitoring dashboard
&lt;/h2&gt;

&lt;p&gt;Query this to see your system health:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Current status of all jobs&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; 
  &lt;span class="n"&gt;job_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;last_success&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;EXTRACT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EPOCH&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;last_success&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;minutes_since_success&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;retry_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;error_message&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;cron_health&lt;/span&gt; 
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;job_name&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%retry%'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;last_run&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Real results
&lt;/h2&gt;

&lt;p&gt;Since implementing this system 3 months ago:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Zero silent failures&lt;/li&gt;
&lt;li&gt;4 automatic recoveries from network timeouts&lt;/li&gt;
&lt;li&gt;99.8% job success rate&lt;/li&gt;
&lt;li&gt;2 minutes average recovery time&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You get infrastructure that fixes itself. Your cron jobs report their health. Failed jobs retry automatically. You sleep better knowing your systems won't fail silently.&lt;/p&gt;

&lt;p&gt;Build systems that work without you watching them.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>devops</category>
      <category>automation</category>
    </item>
    <item>
      <title>Six days, six seconds: a CI test against semantic-layer drift on an AI agent</title>
      <dc:creator>Michel Faure </dc:creator>
      <pubDate>Mon, 11 May 2026 10:00:02 +0000</pubDate>
      <link>https://maker.forem.com/michelfaure/six-days-six-seconds-a-ci-test-against-semantic-layer-drift-on-an-ai-agent-4nf0</link>
      <guid>https://maker.forem.com/michelfaure/six-days-six-seconds-a-ci-test-against-semantic-layer-drift-on-an-ai-agent-4nf0</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmrmmh12ksnvs4h7qnrww.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmrmmh12ksnvs4h7qnrww.png" alt="Comic strip — Françoise asks for a business number, Michel builds an AI agent that confidently answers zero, and discovers six days later that his semantic layer was lying to the model." width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The morning I turned the screen
&lt;/h2&gt;

&lt;p&gt;Early April, my Rembrandt bot already knew how to navigate the ERP. Eighteen tools wired in, multi-turn up to three rounds, it could find a student by name, list the unpaid invoices for a workshop, open a course record. When you asked it &lt;em&gt;"count me the active students at Maisons-Laffitte"&lt;/em&gt;, it delivered. When you asked it &lt;em&gt;"what's the outstanding amount per workshop for the current year"&lt;/em&gt;, it floundered, recycling name-search tools and ending up redirecting to an admin page nobody opened. The bot couldn't answer compound analytical questions, and I knew it.&lt;/p&gt;

&lt;p&gt;Friday April 18th, ten thirty. Françoise pivots on her chair from her three-screen cockpit, the time-clock spreadsheet on her left, Sage on her right, and calls out over the partition: &lt;em&gt;« Michel, sur ceux qui sont en CCF cette année, il en reste combien à encaisser d'ici juin ? »&lt;/em&gt; — &lt;em&gt;Michel, the students on a CCF training plan this year, how much is left to collect before June?&lt;/em&gt; I don't have the tool in the bot. I know it before she's finished her sentence. I open the Supabase SQL Editor tab on my machine, type the query by hand, join &lt;code&gt;inscriptions&lt;/code&gt; × &lt;code&gt;echeances_inscription&lt;/code&gt; × &lt;code&gt;contacts&lt;/code&gt;, filter on payment mode, sum &lt;code&gt;montant_prevu&lt;/code&gt; minus &lt;code&gt;montant_paye&lt;/code&gt; on open instalments. Twenty seconds. I turn the screen. She squints, reads the number, jots it on her sticky note, and drops: &lt;em&gt;« Bon allez, c'est ça. »&lt;/em&gt; — &lt;em&gt;Right, that's it.&lt;/em&gt; She pivots back to Sage. I close the tab without a word.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trigger
&lt;/h2&gt;

&lt;p&gt;Sunday April 20th in the evening, I stumble on the Databricks announcement for &lt;a href="https://www.databricks.com/blog/introducing-genie-agent-mode" rel="noopener noreferrer"&gt;Genie Agent Mode&lt;/a&gt;. I read it diagonally. One sentence does it, &lt;em&gt;plan iteratively, run multiple SQL queries, learn from each result, deliver comprehensive reports&lt;/em&gt;. I close the tab knowing I'm going to code that the following weekend.&lt;/p&gt;

&lt;p&gt;That was the right shape. A semantic layer that describes the tables to the model, a planner that writes the SQL, a validator that filters it before execution, a commenter that renders the answer in French to the user. Nothing original, except that with Claude Code I could lay it down cleanly in fifteen days for my context. I wrote ADR-0020 the next Monday, off we went.&lt;/p&gt;

&lt;h2&gt;
  
  
  The build
&lt;/h2&gt;

&lt;p&gt;Phase 1 laid down the semantic layer in TypeScript, not YAML. Seven whitelisted tables, one per file, typed against &lt;code&gt;Database['public']['Tables']&lt;/code&gt;, columns in business language, canonical metrics, declared joins. TS typing buys two things YAML doesn't: safe refactoring when the schema moves, a compile-time error when the contract drifts off a column name. A single registry consumed by the pipeline.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/analytics/semantic/tables/echeances_inscription.ts — pre-fix state, April 26&lt;/span&gt;
&lt;span class="nx"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;statut&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Payment status: `encaisse` (cash received), `a_payer`, `en_retard`, `annule`&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;refAdr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ADR-0015&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="nx"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;ca_encaisse&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;formula&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SUM(montant_paye) FILTER (WHERE statut = 'encaisse')&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Cash revenue actually received (ADR-0015 cash model).&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;reste_a_encaisser&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;formula&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SUM(montant_prevu - COALESCE(montant_paye,0)) &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;FILTER (WHERE statut IN ('a_payer','en_retard'))&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Open receivables.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Phase 2 locked the database. A Postgres &lt;code&gt;agent_readonly&lt;/code&gt; role with strict &lt;code&gt;SELECT&lt;/code&gt; on the seven tables, an application-side SQL validator (&lt;code&gt;lib/analytics/sql-validator.ts&lt;/code&gt;) on top of node-sql-parser. Two belts. The validator refuses DML, anything off-whitelist, and requires the &lt;code&gt;tenantFilter&lt;/code&gt; via the &lt;code&gt;site_filter&lt;/code&gt; JWT claim. Twenty tests out of twenty green.&lt;/p&gt;

&lt;p&gt;I could have stopped there. I wanted to measure.&lt;/p&gt;

&lt;p&gt;Phase 3 routed the whole thing: Sonnet 4.6 for the plan in tool-use, Haiku 4.5 for the post-execution comment. Haiku bills output five times less than Sonnet on standard French, p50 moves from fifteen to twelve seconds.&lt;/p&gt;

&lt;p&gt;At that stage I had the feeling of clean work. That's exactly the stage at which I laid a trap I wouldn't see for six days.&lt;/p&gt;

&lt;h2&gt;
  
  
  The silent trap
&lt;/h2&gt;

&lt;p&gt;Smoke test of the ten eval-set questions, April 26th early afternoon. Question number eight, &lt;em&gt;"how much is left to collect per workshop for the 2025-2026 year"&lt;/em&gt;. Sonnet plans, the validator accepts, the &lt;code&gt;agent_query_run&lt;/code&gt; RPC comes back green, Haiku writes the comment in correct French. No exception, no Sentry warning. Tick, question nine.&lt;/p&gt;

&lt;p&gt;What I didn't look at in the moment, because nothing pushed me to, was the value of &lt;code&gt;result_row_count&lt;/code&gt; in &lt;code&gt;agent_runs&lt;/code&gt; for that specific run.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- generated by Sonnet 4.6, validated by node-sql-parser, executed by agent_readonly&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;atelier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;montant_prevu&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;montant_paye&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
         &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;statut&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'a_payer'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'en_retard'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;reste_a_encaisser&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;echeances_inscription&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;contacts&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;contact_id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;site&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;ANY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[])&lt;/span&gt;           &lt;span class="c1"&gt;-- site_filter, JWT claim&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;statut&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'liste_rouge'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;atelier&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;reste_a_encaisser&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="n"&gt;NULLS&lt;/span&gt; &lt;span class="k"&gt;LAST&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the Haiku comment, rendered to the user, rationalising the absence:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;For the 2025-2026 year, the outstanding amount per workshop comes out at zero across all sites. This may indicate that the year's direct debits are up to date, or that open instalments are recorded under a different status. For a finer view, see &lt;code&gt;/finance/cash&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The SQL is correct against the contract. The RPC confirms it. And the contract is wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The query, by hand
&lt;/h2&gt;

&lt;p&gt;The doubt came in the evening, cold, rereading the ten runs in &lt;code&gt;/admin/rembrandt/analytics-runs&lt;/code&gt;. Three out of ten questions had &lt;code&gt;result_row_count&lt;/code&gt; at zero, on numbers I knew the order of magnitude of. I opened psql, typed the shortest query in the world.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;rembrandt&lt;/span&gt;&lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;statut&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;echeances_inscription&lt;/span&gt;
            &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;statut&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="n"&gt;statut&lt;/span&gt;   &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;
&lt;span class="c1"&gt;-----------+-------&lt;/span&gt;
 &lt;span class="n"&gt;preleve&lt;/span&gt;   &lt;span class="o"&gt;|&lt;/span&gt;  &lt;span class="mi"&gt;1630&lt;/span&gt;
 &lt;span class="n"&gt;planifie&lt;/span&gt;  &lt;span class="o"&gt;|&lt;/span&gt;   &lt;span class="mi"&gt;158&lt;/span&gt;
 &lt;span class="n"&gt;annule&lt;/span&gt;    &lt;span class="o"&gt;|&lt;/span&gt;     &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three statuses, one thousand seven hundred and eighty-nine rows total, and not one value in common with the four I had declared in the semantic layer. &lt;em&gt;No&lt;/em&gt; &lt;code&gt;encaisse&lt;/code&gt;. &lt;em&gt;No&lt;/em&gt; &lt;code&gt;a_payer&lt;/code&gt;. &lt;em&gt;No&lt;/em&gt; &lt;code&gt;en_retard&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The semantic layer documented &lt;code&gt;encaisse | a_payer | en_retard | annule&lt;/code&gt;. The database held &lt;code&gt;preleve | planifie | annule&lt;/code&gt;. The three canonical metrics &lt;code&gt;ca_encaisse&lt;/code&gt;, &lt;code&gt;reste_a_encaisser&lt;/code&gt;, &lt;code&gt;nb_echeances_en_retard&lt;/code&gt; were all filtering on values that didn't exist. Sonnet was doing its job, the validator was doing its job, Postgres was doing its job, and the answer rendered to the user was rigorously zero, presented in clean French.&lt;/p&gt;

&lt;p&gt;The origin of the drift is ridiculous. Phase 1 of the semantic layer had been built on &lt;code&gt;docs/agent-analytique/eval-set-v1.md&lt;/code&gt;, a document I had written myself in conceptual intentions. The Postgres migration, laid weeks earlier on a different reasoning (Stripe workflow, direct debit, scheduling), had recorded &lt;code&gt;preleve | planifie | annule&lt;/code&gt;. I wrote the semantic layer looking at the documentation instead of querying the database.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;p&gt;Sculley et al. published a paper in 2015 that became canonical, &lt;a href="https://papers.nips.cc/paper_files/paper/2015/hash/86df7dcfd896fcaf2674f757a2463eba-Abstract.html" rel="noopener noreferrer"&gt;&lt;em&gt;Hidden Technical Debt in Machine Learning Systems&lt;/em&gt;&lt;/a&gt;. Their notion of &lt;strong&gt;configuration debt&lt;/strong&gt;: a system accrues debt in the layer that &lt;em&gt;describes&lt;/em&gt; it, just as much as in the code that runs it. The semantic layer of a SQL agent is exactly that layer.&lt;/p&gt;

&lt;p&gt;A semantic layer is a second database. It has its schema, its constraints, and like any database it drifts if you don't audit it. What the Genie pattern does not eliminate is schema risk. It just shifts it onto the translation layer it introduces, and it makes the error silent because the SQL produced stays valid.&lt;/p&gt;

&lt;p&gt;The trap wasn't in Genie. The trap was in the picture I had built of my own data.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you can copy
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Seed the enums from the database, not from the documentation.&lt;/strong&gt; A script that reads the database at TS-module generation time, and the contract sticks to the schema with no human in the loop. The documentation stays a writing guide, not a source.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// scripts/sync-semantic-enums.ts — run in pre-commit or in CI&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/supabase-admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;writeFileSync&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:fs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;targets&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;echeances_inscription&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;statut&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;inscriptions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;statut&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;contacts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;statut&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;targets&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;values&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`export const &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_enum = &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt; as const\n`&lt;/span&gt;
  &lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`lib/analytics/semantic/generated/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.ts`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Test consistency in CI.&lt;/strong&gt; The test fails if the layer declares a status the database no longer carries, or vice versa. Six days of drift collapse into six seconds.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// __tests__/semantic-drift.test.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vitest&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;semanticTables&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/analytics/semantic&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/supabase-admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;semantic layer drift&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;table&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;semanticTables&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;def&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;def&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kr"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;
      &lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; matches DB`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;real&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;real&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;def&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kr"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Surface &lt;code&gt;agent_runs.result_row_count = 0&lt;/code&gt;&lt;/strong&gt; in an admin page with a rolling seven-day filter. The table is already there, it just needs to be read. A daily share-of-zero-rows graph, and the drift shows up to the eye.&lt;/p&gt;

&lt;p&gt;If you maintain a semantic layer in TS on Postgres, the test above wires in in under an hour and tells you immediately where you're lying to your agent. On Rembrandt that signal didn't exist before that Friday.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Companion code&lt;/strong&gt;: &lt;a href="https://github.com/michelfaure/rembrandt-samples/tree/main/semantic-layer-drift" rel="noopener noreferrer"&gt;&lt;code&gt;rembrandt-samples/semantic-layer-drift/&lt;/code&gt;&lt;/a&gt; — enum sync script, Vitest drift test, and &lt;code&gt;agent_runs&lt;/code&gt; schema with the zero-row canary index, MIT, copy-pastable.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>ai</category>
      <category>claudecode</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Mastering Gemini Nano: Building a High-Performance On-Device AI Chat UI with Jetpack Compose</title>
      <dc:creator>Programming Central</dc:creator>
      <pubDate>Mon, 11 May 2026 10:00:00 +0000</pubDate>
      <link>https://maker.forem.com/programmingcentral/mastering-gemini-nano-building-a-high-performance-on-device-ai-chat-ui-with-jetpack-compose-16h2</link>
      <guid>https://maker.forem.com/programmingcentral/mastering-gemini-nano-building-a-high-performance-on-device-ai-chat-ui-with-jetpack-compose-16h2</guid>
      <description>&lt;p&gt;The landscape of mobile development is shifting beneath our feet. For years, the "Smart" in smartphone relied almost exclusively on the cloud. We sent a request, waited for a server in a distant data center to process it, and received a response. But with the advent of Gemini Nano and Google’s AICore, the intelligence is moving directly onto the silicon in our pockets. &lt;/p&gt;

&lt;p&gt;Building a Chat UI for an on-device Large Language Model (LLM) like Gemini Nano is not just another exercise in creating a list of text bubbles. It is a fundamental departure from the traditional CRUD (Create, Read, Update, Delete) applications we’ve built for a decade. It requires a deep understanding of hardware orchestration, asynchronous data streams, and state management that can handle the heavy lifting of generative AI without freezing the user interface.&lt;/p&gt;

&lt;p&gt;In this guide, we will dive deep into the architectural paradigms of on-device AI, explore why AICore is a game-changer for Android developers, and implement a production-grade chat interface using Jetpack Compose and Kotlin Coroutines.&lt;br&gt;
(This article is based on the ebook &lt;a href="https://leanpub.com/OnDeviceGenAIWithAndroidKotlin?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=android&amp;amp;utm_content=top_article_link" rel="noopener noreferrer"&gt;On-Device GenAI with Android Kotlin&lt;/a&gt;)&lt;/p&gt;
&lt;h2&gt;
  
  
  The Architectural Paradigm of On-Device AI Interfaces
&lt;/h2&gt;

&lt;p&gt;When you build a standard chat app—think WhatsApp or Slack—the data flow is discrete. You send a message, it hits a database, and a notification triggers a fetch on the other end. In the world of Generative AI (GenAI), this model breaks down.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Challenge of the "Token Stream"
&lt;/h3&gt;

&lt;p&gt;The core theoretical challenge in GenAI is managing what we call the &lt;strong&gt;Token Stream&lt;/strong&gt;. LLMs do not generate sentences; they generate text one token at a time. If you were to wait for Gemini Nano to finish generating a 500-word response before displaying it, the user would be staring at a "Thinking..." spinner for five to ten seconds. In the world of modern UX, that is an eternity.&lt;/p&gt;

&lt;p&gt;To solve this, your UI must be designed as a &lt;strong&gt;reactive sink&lt;/strong&gt;. It needs to be capable of receiving a continuous, high-frequency stream of data and updating the display in real-time. This ensures a sense of immediacy, making the AI feel like it is "typing" its thoughts as they occur.&lt;/p&gt;
&lt;h3&gt;
  
  
  AICore: The System-Level AI Provider
&lt;/h3&gt;

&lt;p&gt;Why can't we just bundle a model file in our APK and call it a day? The answer lies in the constraints of mobile hardware. LLMs are resource monsters. They demand massive amounts of RAM (often several gigabytes) and require direct, low-level access to the Neural Processing Unit (NPU).&lt;/p&gt;

&lt;p&gt;If every app on a user’s phone bundled its own version of Gemini Nano, the device’s storage would vanish, and the RAM would be so fragmented that the OS would constantly kill background processes. Google’s solution is &lt;strong&gt;AICore&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;AICore acts as a system-level service, much like &lt;strong&gt;CameraX&lt;/strong&gt; or &lt;strong&gt;Google Play Services&lt;/strong&gt;. It provides several critical advantages for the modern Android developer:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Shared Memory Architecture:&lt;/strong&gt; The model is loaded into system memory once. Whether the user is using your app, a notes app, or a messaging app, they all interface with the same resident model, drastically reducing the total memory footprint.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Seamless Model Updates:&lt;/strong&gt; Google can refine the model weights, improve safety filters, and optimize performance via Play Store updates to AICore. As a developer, you don't need to push a new APK just because the underlying LLM got smarter.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Hardware Orchestration:&lt;/strong&gt; This is perhaps the most vital role. AICore manages the handoff between the CPU, GPU, and NPU. It balances "tokens-per-second" against thermal throttling. It knows when to push the NPU to its limit and when to scale back to prevent the user's phone from becoming uncomfortably hot.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  The Model Loading Analogy: It’s Not Just a Class
&lt;/h2&gt;

&lt;p&gt;Loading a local LLM is a "heavy lift." To help visualize this, think of the initial loading process as being similar to a &lt;strong&gt;Room database migration&lt;/strong&gt;. &lt;/p&gt;

&lt;p&gt;When you perform a complex database migration, you are dealing with disk I/O, schema validation, and data integrity checks. If you do this on the main thread, the app hangs. Loading Gemini Nano involves allocating large contiguous blocks of VRAM, verifying model checksums, and "warming up" the NPU. If the model is not already resident in memory, the first request will experience a "cold start" latency. &lt;/p&gt;

&lt;p&gt;Your UI must explicitly account for this. A professional AI app isn't just &lt;code&gt;Loading&lt;/code&gt; or &lt;code&gt;Success&lt;/code&gt;. It needs a state machine that handles &lt;code&gt;Initializing&lt;/code&gt;, &lt;code&gt;ModelLoading&lt;/code&gt;, &lt;code&gt;Ready&lt;/code&gt;, and &lt;code&gt;InferenceInProgress&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Connecting Modern Kotlin to AI Workflows
&lt;/h2&gt;

&lt;p&gt;To implement this architecture, we leverage the latest features of Kotlin 2.x. These tools aren't just syntactic sugar; they are the engine that makes high-performance AI possible on mobile.&lt;/p&gt;
&lt;h3&gt;
  
  
  1. Kotlin Flow for Real-Time Streaming
&lt;/h3&gt;

&lt;p&gt;Since Gemini Nano emits tokens incrementally, &lt;code&gt;Flow&lt;/code&gt; is the non-negotiable choice for data transport. Specifically, we use &lt;code&gt;Flow&amp;lt;String&amp;gt;&lt;/code&gt; to stream the response. Unlike a static &lt;code&gt;List&lt;/code&gt;, a &lt;code&gt;Flow&lt;/code&gt; allows the UI to append text to the last message bubble in real-time. &lt;/p&gt;
&lt;h3&gt;
  
  
  2. Coroutines and Dispatcher Management
&lt;/h3&gt;

&lt;p&gt;AI inference is computationally expensive. While AICore handles the heavy lifting, the coordination of prompts and the processing of the resulting stream must happen on &lt;code&gt;Dispatchers.Default&lt;/code&gt;. If you attempt to process these tokens on the &lt;code&gt;Main&lt;/code&gt; thread, you will drop frames, and your beautiful Compose animations will stutter.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. Kotlin Serialization for Prompt Engineering
&lt;/h3&gt;

&lt;p&gt;Modern AI development relies heavily on structured prompts. Using &lt;code&gt;kotlinx.serialization&lt;/code&gt;, we can define "Prompt Templates" as data classes. This ensures that the input sent to Gemini Nano is consistent, type-safe, and follows the specific formatting required for the model to understand context.&lt;/p&gt;
&lt;h2&gt;
  
  
  The State Machine of a Chat UI
&lt;/h2&gt;

&lt;p&gt;Before we look at the code, we must define the state. A GenAI Chat UI is best represented as a &lt;strong&gt;Finite State Machine (FSM)&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;IDLE:&lt;/strong&gt; The user is typing. The system is waiting.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;PROMPTING:&lt;/strong&gt; The request is sent to AICore. The UI shows a "Thinking..." indicator.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;STREAMING:&lt;/strong&gt; Tokens are arriving. The UI is actively appending text to the latest message.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;COMPLETED:&lt;/strong&gt; The LLM has emitted the &lt;code&gt;end_of_turn&lt;/code&gt; token. The UI transitions back to a state where the user can send a follow-up.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;ERROR:&lt;/strong&gt; The model failed (e.g., safety filters triggered or Out-of-Memory). The UI must provide a recovery path.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Implementation: The Technical Stack
&lt;/h2&gt;

&lt;p&gt;Let's look at how to build this. We will use Hilt for Dependency Injection to ensure our AI repository is a singleton, preventing multiple instances from attempting to lock the NPU hardware.&lt;/p&gt;
&lt;h3&gt;
  
  
  Gradle Dependencies
&lt;/h3&gt;

&lt;p&gt;First, ensure your &lt;code&gt;build.gradle.kts&lt;/code&gt; is equipped with the necessary libraries for MediaPipe (which powers the Gemini Nano integration) and Jetpack Compose.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nf"&gt;dependencies&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// MediaPipe GenAI for Gemini Nano&lt;/span&gt;
    &lt;span class="nf"&gt;implementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"com.google.mediapipe:tasks-genai:0.10.14"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;// Jetpack Compose&lt;/span&gt;
    &lt;span class="nf"&gt;implementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"androidx.compose.ui:ui:1.7.0"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;implementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"androidx.compose.material3:material3:1.2.0"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;implementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"androidx.lifecycle:lifecycle-viewmodel-compose:2.8.0"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;implementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"androidx.lifecycle:lifecycle-runtime-compose:2.8.0"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;// Hilt for Dependency Injection&lt;/span&gt;
    &lt;span class="nf"&gt;implementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"com.google.dagger:hilt-android:2.51"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;kapt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"com.google.dagger:hilt-compiler:2.51"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;// Coroutines &amp;amp; Serialization&lt;/span&gt;
    &lt;span class="nf"&gt;implementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;implementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Data Layer: Hardware-Aware Repository
&lt;/h3&gt;

&lt;p&gt;The repository is where the "magic" happens. It abstracts the MediaPipe &lt;code&gt;LlmInference&lt;/code&gt; engine and provides a clean &lt;code&gt;Flow&lt;/code&gt; for the ViewModel to consume.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Singleton&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OnDeviceChatRepository&lt;/span&gt; &lt;span class="nd"&gt;@Inject&lt;/span&gt; &lt;span class="k"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nd"&gt;@ApplicationContext&lt;/span&gt; &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Context&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;llmInference&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;LlmInference&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;

    &lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;initializeModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;modelPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;withContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Dispatchers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Default&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LlmInference&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;LlmInferenceOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setModelPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;modelPath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setMaxTokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTemperature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.7f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTopK&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="n"&gt;llmInference&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LlmInference&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createFromOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;generateResponseStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Flow&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;callbackFlow&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;inference&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;llmInference&lt;/span&gt; &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nc"&gt;IllegalStateException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Model not initialized"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c1"&gt;// Generate response asynchronously to keep the flow non-blocking&lt;/span&gt;
        &lt;span class="n"&gt;inference&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generateResponseAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;partialResult&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;done&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
            &lt;span class="nf"&gt;trySend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;partialResult&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nf"&gt;awaitClose&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* Cleanup resources if necessary */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="nf"&gt;flowOn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Dispatchers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Default&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The ViewModel: Orchestrating State
&lt;/h3&gt;

&lt;p&gt;The ViewModel acts as the bridge. It takes user input, updates the UI to show the user's message, and then manages the stream coming back from the AI.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@HiltViewModel&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ChatViewModel&lt;/span&gt; &lt;span class="nd"&gt;@Inject&lt;/span&gt; &lt;span class="k"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;OnDeviceChatRepository&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ViewModel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;_uiState&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MutableStateFlow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChatUiState&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;uiState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;StateFlow&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ChatUiState&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_uiState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asStateFlow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;sendMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userText&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userText&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isBlank&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;

        &lt;span class="c1"&gt;// 1. Add user message to the list&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;userMsg&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ChatMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userText&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;isUser&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;_uiState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;userMsg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;isTyping&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;viewModelScope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;fullAiResponse&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;

            &lt;span class="c1"&gt;// 2. Collect the stream from the repository&lt;/span&gt;
            &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generateResponseStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userText&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onStart&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="c1"&gt;// Add an empty placeholder for the AI response&lt;/span&gt;
                    &lt;span class="n"&gt;_uiState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="nc"&gt;ChatMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;isUser&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collect&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
                    &lt;span class="n"&gt;fullAiResponse&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;

                    &lt;span class="c1"&gt;// 3. Update the last message in the list with the new token&lt;/span&gt;
                    &lt;span class="n"&gt;_uiState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
                        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;updatedMessages&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toMutableList&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;lastIdx&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;updatedMessages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lastIndex&lt;/span&gt;
                        &lt;span class="n"&gt;updatedMessages&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;lastIdx&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;updatedMessages&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;lastIdx&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fullAiResponse&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                        &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;updatedMessages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="n"&gt;_uiState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;isTyping&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The UI Layer: Jetpack Compose Chat Screen
&lt;/h3&gt;

&lt;p&gt;In Compose, we use &lt;code&gt;LazyColumn&lt;/code&gt; to render the messages. A key trick here is using &lt;code&gt;LaunchedEffect&lt;/code&gt; to auto-scroll to the bottom as the AI "types."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Composable&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;ChatScreen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;viewModel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ChatViewModel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;uiState&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;viewModel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uiState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collectAsStateWithLifecycle&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;inputText&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="nf"&gt;remember&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;mutableStateOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;listState&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rememberLazyListState&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="c1"&gt;// Auto-scroll logic&lt;/span&gt;
    &lt;span class="nc"&gt;LaunchedEffect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uiState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uiState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lastOrNull&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uiState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isNotEmpty&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;listState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;animateScrollToItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uiState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;modifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Modifier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillMaxSize&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;LazyColumn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;listState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;modifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Modifier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1f&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fillMaxWidth&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;verticalArrangement&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Arrangement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;spacedBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uiState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
                &lt;span class="nc"&gt;ChatBubble&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nc"&gt;Row&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;verticalAlignment&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Alignment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CenterVertically&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;TextField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;inputText&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;onValueChange&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;inputText&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="n"&gt;modifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Modifier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1f&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="n"&gt;placeholder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Ask Gemini Nano..."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nc"&gt;IconButton&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;onClick&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;viewModel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inputText&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;inputText&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;
            &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nc"&gt;Icon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Icons&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Default&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Send&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;contentDescription&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Send"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Performance Pitfalls to Avoid
&lt;/h2&gt;

&lt;p&gt;Building for on-device AI requires a higher level of discipline than standard app development. Here are the most common pitfalls:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Main Thread Inference:&lt;/strong&gt; Never, ever call the AI model on the Main thread. Even a small model will block the UI for hundreds of milliseconds, leading to "Application Not Responding" (ANR) errors.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Memory Management:&lt;/strong&gt; Local LLMs are heavy. If you are not using AICore and are instead bundling your own TFLite model, you must manually close the &lt;code&gt;Interpreter&lt;/code&gt; or &lt;code&gt;LlmInference&lt;/code&gt; instance in the ViewModel's &lt;code&gt;onCleared()&lt;/code&gt; method to prevent massive native memory leaks.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Ignoring Lifecycle:&lt;/strong&gt; Use &lt;code&gt;collectAsStateWithLifecycle()&lt;/code&gt;. If the user moves the app to the background, you want the UI collection to pause to save battery, even if the AI continues to process the current prompt in the background.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Over-Recomposition:&lt;/strong&gt; When streaming tokens, the state updates rapidly. Ensure your &lt;code&gt;ChatBubble&lt;/code&gt; composables are optimized and use &lt;code&gt;remember&lt;/code&gt; for any expensive UI calculations to keep the frame rate smooth.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Conclusion: The New Frontier
&lt;/h2&gt;

&lt;p&gt;Creating a Chat UI with Jetpack Compose for Gemini Nano is more than just a UI task; it's a lesson in modern systems architecture. By leveraging AICore, we move away from the "Cloud-First" mentality and toward a "Privacy-First, Latency-Zero" future. &lt;/p&gt;

&lt;p&gt;The combination of Kotlin's reactive streams and Compose's declarative UI provides the perfect foundation for this new era of mobile computing. As on-device NPUs continue to evolve, the gap between what a phone can do and what a server can do will continue to shrink.&lt;/p&gt;

&lt;h3&gt;
  
  
  Let's Discuss
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt; Given the memory constraints of mobile devices, do you think AICore's shared model approach is the right move, or should developers have the freedom to bundle custom, fine-tuned models despite the storage cost?&lt;/li&gt;
&lt;li&gt; How do you see the role of the "Mobile Developer" changing as prompt engineering and local inference become standard parts of the Android SDK?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The concepts and code demonstrated here are drawn directly from the comprehensive roadmap laid out in the ebook&lt;br&gt;
&lt;strong&gt;On-Device GenAI with Android Kotlin: Mastering Gemini Nano, AICore, and local LLM deployment using MediaPipe and Custom TFLite models&lt;/strong&gt;. You can find it here: &lt;a href="https://leanpub.com/OnDeviceGenAIWithAndroidKotlin?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=android&amp;amp;utm_content=bottom_article_link" rel="noopener noreferrer"&gt;Leanpub.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Check also all the other programming &amp;amp; AI ebooks with python, typescript, c#, swift, kotlin: &lt;a href="https://leanpub.com/u/edgarmilvus?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=android&amp;amp;utm_content=bottom_article_lin&amp;lt;br&amp;gt;%0Ak" rel="noopener noreferrer"&gt;Leanpub.com&lt;/a&gt; &lt;/p&gt;

&lt;p&gt;Android Kotlin &amp;amp; AI Masterclass:&lt;br&gt;
Book 1: On-Device GenAI. Mastering Gemini Nano, AICore, and local LLM deployment using MediaPipe and Custom TFLite models.&lt;br&gt;
Book 2: Edge AI Performance. Optimizing hardware acceleration via NPU (Neural Processing Unit), GPU, and DSP. Advanced quantization and model pruning.&lt;br&gt;
Book 3: Android AI Agents. Building autonomous apps that use Tool Calling, Function Injection, and Screen Awareness to perform tasks for the user.&lt;/p&gt;

</description>
      <category>android</category>
      <category>kotlin</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
