<?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>DEV Community: Atsushi Hara</title>
    <description>The latest articles on DEV Community by Atsushi Hara (@toohsk).</description>
    <link>https://dev.to/toohsk</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F760345%2F0dbef7ff-b378-4de0-bf63-f03a98c350f3.jpeg</url>
      <title>DEV Community: Atsushi Hara</title>
      <link>https://dev.to/toohsk</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/toohsk"/>
    <language>en</language>
    <item>
      <title>Can We Stop Running Recurring Analysis as a Scheduled LLM Prompt?</title>
      <dc:creator>Atsushi Hara</dc:creator>
      <pubDate>Wed, 19 Aug 2026 04:25:34 +0000</pubDate>
      <link>https://dev.to/toohsk/can-we-stop-running-recurring-analysis-as-a-scheduled-llm-prompt-5eo9</link>
      <guid>https://dev.to/toohsk/can-we-stop-running-recurring-analysis-as-a-scheduled-llm-prompt-5eo9</guid>
      <description>&lt;p&gt;Every major assistant now ships some version of scheduled prompts. You write "every Monday at 9am, analyze last week's signup funnel and tell me what changed," pick a cadence, and it runs.&lt;/p&gt;

&lt;p&gt;For a news briefing, this is great. For analysis, I think it's a mistake — and not a small one. I want to argue that the problem isn't the scheduler. It's &lt;em&gt;where the LLM sits&lt;/em&gt; relative to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a recurring analysis is actually for
&lt;/h2&gt;

&lt;p&gt;Nobody schedules an analysis because they want a report. They schedule it because they want to know &lt;strong&gt;what changed&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That's the whole purpose. A one-off analysis answers "what is the state of things." A recurring analysis answers "is the state of things moving, and in which direction." The second question is the only reason the cadence exists.&lt;/p&gt;

&lt;p&gt;And detecting change requires one thing above all else: &lt;strong&gt;the instrument has to hold still&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the ruler changes length
&lt;/h2&gt;

&lt;p&gt;If the query is regenerated from a natural language prompt on every run, then a movement in your number has two possible causes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The world changed.&lt;/li&gt;
&lt;li&gt;The measurement changed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And you have no way to separate them. This is textbook confounding, and you've built it into the foundation of your monitoring.&lt;/p&gt;

&lt;p&gt;The tempting response is "modern models are good at SQL, it'll be fine." But accuracy isn't really the issue — consistency is. Even a model that writes correct SQL every single time can write &lt;em&gt;differently correct&lt;/em&gt; SQL each run. Here is the failure mode that actually bites:&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 1&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&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;users&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;subscriptions&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;ON&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;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2026-08-01'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="s1"&gt;'2026-09-01'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Run 2 — same prompt, two weeks later&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&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;users&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;
&lt;span class="k"&gt;INNER&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;subscriptions&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;ON&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;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;   &lt;span class="c1"&gt;-- users with no subscription silently vanish&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'2026-08-01'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="s1"&gt;'2026-09-01'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- window shifted by a day&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Neither query is broken. Neither throws an error. Both are defensible readings of the same English sentence. The number just moves by a few percent.&lt;/p&gt;

&lt;p&gt;The usual suspects, in my experience:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;LEFT JOIN&lt;/code&gt; quietly becoming &lt;code&gt;INNER JOIN&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Period boundaries flipping between &lt;code&gt;&amp;gt;=&lt;/code&gt; / &lt;code&gt;&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;&lt;/code&gt; / &lt;code&gt;&amp;lt;=&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;NOT IN&lt;/code&gt; vs &lt;code&gt;NOT EXISTS&lt;/code&gt; and how each treats &lt;code&gt;NULL&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;COUNT(*)&lt;/code&gt; vs &lt;code&gt;COUNT(DISTINCT ...)&lt;/code&gt; on a fanned-out join&lt;/li&gt;
&lt;li&gt;Timezone: &lt;code&gt;UTC&lt;/code&gt; vs local, and whether the day boundary matches the rest of the org&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is worse than an outright failure. A crash is loud and you fix it. This is silent, plausible, and it corrupts the time series. Six months later, somebody asks why March looks odd, and you cannot reconstruct what query produced March's number — because it no longer exists anywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  LLM as compiler, not as runtime
&lt;/h2&gt;

&lt;p&gt;The fix isn't to stop using LLMs. It's to move the LLM from &lt;em&gt;runtime&lt;/em&gt; to &lt;em&gt;build time&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Concretely, split the work into phases:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Explore — interactive, LLM-heavy.&lt;/strong&gt; This is where "analyze the signup funnel" belongs. Iterate, argue with the model, throw things away. Nondeterminism is a feature here; you &lt;em&gt;want&lt;/em&gt; different angles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Freeze — emit code.&lt;/strong&gt; Once you know what you're measuring, have the model write it out as SQL or a script, and put it in version control. The metric definition is now an artifact with a name and a hash.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Review — a human reads the diff.&lt;/strong&gt; This step looks bureaucratic and it is the single most valuable one. It converts "the metric definition changed" from an invisible accident into an explicit, attributable, reviewable event. A scheduled prompt can never give you this, because there is no diff to look at — the change happens inside a sampling distribution.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Run — cron, or whatever your orchestrator is.&lt;/strong&gt; Deterministic code, deterministic schedule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Interpret — LLM again, downstream.&lt;/strong&gt; More on this below.&lt;/p&gt;

&lt;p&gt;If this sounds familiar, it's because the BI world has already had this argument and reached the same conclusion. dbt's Semantic Layer, Omni, Dremio, Cortex Analyst — the shared thesis is that metric definitions must be codified so the LLM chooses &lt;em&gt;which&lt;/em&gt; metric rather than &lt;em&gt;how to compute&lt;/em&gt; it. dbt make the point directly: with a semantic layer, the model can't produce correct-looking numbers that differ subtly between runs, because the logic is fixed.&lt;/p&gt;

&lt;p&gt;What strikes me is that this conversation is happening almost entirely inside data engineering, in the vocabulary of text-to-SQL and governance. Meanwhile the "schedule an AI task" conversation is happening somewhere else entirely, in productivity blogs, and the two have not met. They're the same problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the LLM legitimately stays
&lt;/h2&gt;

&lt;p&gt;I don't want this to read as "keep LLMs out of the pipeline." There are two places they belong, and once you look closely they're the same place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Interpreting the numbers.&lt;/strong&gt; The metric is computed by frozen code. Then you hand the resulting numbers to a model and ask for commentary: "conversion fell 12% week over week, concentrated in the paid-social cohort." That's a judgment, it's genuinely hard to express as code, and — crucially — the model is &lt;em&gt;consuming&lt;/em&gt; numbers, not producing them. The nondeterminism stays downstream of the measurement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Understanding unstructured input.&lt;/strong&gt; If a step in your pipeline classifies free-text support tickets by theme, or extracts entities from a PDF, no amount of SQL is going to replace it. This is exactly what language models are for.&lt;/p&gt;

&lt;p&gt;The unifying shape: in both cases the LLM's job is &lt;strong&gt;natural language in, fixed structure out&lt;/strong&gt;. It's a function with a declared output contract. What it must never be is the thing that &lt;em&gt;decides&lt;/em&gt; the contract.&lt;/p&gt;

&lt;p&gt;So the line I'd draw isn't "LLM or not." It's:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The LLM may consume or produce values. It may not define how values are computed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What freezing doesn't fix, and what to do about it
&lt;/h2&gt;

&lt;p&gt;Freezing the query removes one source of drift. It doesn't make the pipeline safe, and I think it's worth being honest that two failure modes survive — one on the data side, one on the model side.&lt;/p&gt;

&lt;h3&gt;
  
  
  Data-side drift
&lt;/h3&gt;

&lt;p&gt;Your schema and your semantics keep moving even when your SQL doesn't.&lt;/p&gt;

&lt;p&gt;Someone adds a new value to an enum. An event gets renamed and the old name is dual-written for a month. A column's meaning quietly changes because a new signup flow populates it differently. Your frozen query keeps running, keeps returning numbers, and keeps being wrong.&lt;/p&gt;

&lt;p&gt;Note that this is &lt;em&gt;the same silent-plausible-drift problem&lt;/em&gt; as before, just relocated. Freezing moved it, it didn't kill it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Countermeasure: assertions alongside the metric.&lt;/strong&gt; Every scheduled run should check its own inputs and outputs:&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;# Guard the metric, not just the job. A green exit code means nothing
# if the underlying data changed shape.
&lt;/span&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="mi"&gt;8_000&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;row_count&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;20_000&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;row count outside expected band: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;row_count&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;null_rate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;plan_type&lt;/span&gt;&lt;span class="sh"&gt;"&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.02&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;null rate spiked — upstream schema change?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;observed_categories&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;KNOWN_CATEGORIES&lt;/span&gt;&lt;span class="p"&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;unknown category values: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;observed_categories&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;KNOWN_CATEGORIES&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;The important part is what happens when these fire. Don't treat it as a flaky job to be retried. Treat it as a signal that &lt;strong&gt;the metric definition needs to be revisited by a human&lt;/strong&gt;. The architecture is: freeze, plus an automated trigger to reconsider the freeze.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Countermeasure: version the metric itself.&lt;/strong&gt; Stamp a &lt;code&gt;metric_version&lt;/code&gt; on every output row and surface it on the dashboard. When you revise a definition, you then have to make an explicit decision — backfill history under the new definition, or break the series and show the break — instead of silently splicing two incompatible time series together and squinting at the result.&lt;/p&gt;

&lt;h3&gt;
  
  
  Model-side drift
&lt;/h3&gt;

&lt;p&gt;If an LLM inference step remains in the pipeline, it has its own drift, from two directions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The provider changes the model under you.&lt;/strong&gt; Pin model versions explicitly. Treat an unpinned model in a scheduled pipeline the same way you'd treat &lt;code&gt;pip install&lt;/code&gt; without a lockfile.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The input distribution changes under a pinned model.&lt;/strong&gt; Pinning is necessary but not sufficient. If your ticket classifier suddenly reports 30% more "billing" tickets, is that because customers are complaining more about billing, or because the phrasing of incoming tickets shifted in a way that pushes the classifier around? Same confound as at the start of this post, one layer down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Countermeasure: a golden set on every run.&lt;/strong&gt; Keep a fixed, hand-labeled sample and push it through the classifier alongside the real data each time. If the golden set's outputs move, the classifier moved. If the golden set is stable and production numbers moved, the world moved. Now you can actually tell the two apart — which was the entire point of scheduling the thing in the first place.&lt;/p&gt;

&lt;h3&gt;
  
  
  What else breaks? I'd genuinely like to know
&lt;/h3&gt;

&lt;p&gt;These two are the failure modes I've run into myself, which almost certainly means the list is incomplete rather than complete. I'm sure there are whole categories here I haven't hit yet — late-arriving data and how it interacts with a frozen window, DST and the days that have 23 or 25 hours in them, warehouse engine upgrades changing float or sort behavior, upstream backfills silently rewriting history under a metric you already published, whatever else is out there.&lt;/p&gt;

&lt;p&gt;If you've operated something like this in production and watched it break in a way I haven't described, &lt;strong&gt;please leave a comment&lt;/strong&gt;. I'd like to turn this section into something more like a proper catalogue, and I'll learn more from your war stories than from anything else. Disagreement is very welcome too — if you think scheduled prompts are fine for analysis and I've overstated the risk, I want to hear the case.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-line version
&lt;/h2&gt;

&lt;p&gt;The scheduler was never the problem. Cron is fine. Airflow is fine. The problem is putting a probabilistic process where a fixed instrument needs to be.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Let the LLM write the pipeline once. Don't let it be the pipeline.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>llm</category>
      <category>dataengineering</category>
      <category>architecture</category>
      <category>automation</category>
    </item>
    <item>
      <title>You can unit test your code. But how do you test your prompts?</title>
      <dc:creator>Atsushi Hara</dc:creator>
      <pubDate>Wed, 12 Aug 2026 08:43:27 +0000</pubDate>
      <link>https://dev.to/toohsk/you-can-unit-test-your-code-but-how-do-you-test-your-prompts-284j</link>
      <guid>https://dev.to/toohsk/you-can-unit-test-your-code-but-how-do-you-test-your-prompts-284j</guid>
      <description>&lt;p&gt;I've been building a web app to manage rolling stock - a preparedness habit where you rotate everyday household food items as emergency supplies, rather than keeping a separate untouched stash.&lt;br&gt;
The app is simple. You take a photo of a product label, and an LLM (currently using Gemini Flash Lite) automatically extracts the product name and expiration date. You can see how many days are left until expiration - enough to know when something needs to be eaten or when to restock.&lt;br&gt;
The implementation went surprisingly smoothly. The LLM's recognition accuracy was more than sufficient. While building it, I thought, "This is pretty solid - the core feature is basically done."&lt;br&gt;
but I was wrong.&lt;/p&gt;
&lt;h1&gt;
  
  
  A Gap I Found During Validation
&lt;/h1&gt;

&lt;p&gt;While validating the app with real product labels, I noticed something odd.&lt;br&gt;
A label that only showed "August 2026" was being registered as August 1st, 2026.&lt;br&gt;
In Japan, the convention for expiration dates is that a year-month-only format means the product is valid until the last day of that month. So the correct value should be August 31st, 2026. But the LLM was returning the minimum valid date - the 1st.&lt;br&gt;
For users who know this convention, this means manually correcting the date every single time. For users who don't, it could cause them to rotate food stocks weeks earlier than necessary.&lt;/p&gt;
&lt;h1&gt;
  
  
  Deciding on a&amp;nbsp;Fix
&lt;/h1&gt;

&lt;p&gt;I considered two approaches.&lt;br&gt;
Option A - Instruct via prompt: Tell the LLM to return the last day of the month. The concern of this approach is trusting the LLM with leap year and 30/31-day logic feels risky. LLM outputs are probabilistic - even if it appears to work during testing, I can't be fully confident it will do so 100% of the time in practice.&lt;br&gt;
Option B - Handle in code: Have the LLM return YYYY-MM when only year and month are available, and calculate the last day in code. The concern of this approach is the impact of changing the prompt is unclear.&lt;br&gt;
I went with Option B. Calculating the last day of a month is deterministic logic - there's no reason to leave it to a probabilistic model. On top of that, finding real products with leap-year expiration dates to test with would itself be a challenge.&lt;br&gt;
So I kept the prompt change to a single line:&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;// Before&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;expiry_date&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="s2"&gt;Date in YYYY-MM-DD format&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="c1"&gt;// After&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;expiry_date&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="s2"&gt;Return YYYY-MM-DD if the full date is known, or YYYY-MM if only year and month are available&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And handled the rest in code:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;normalizeExpiryDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\d{4}&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;\d{2}&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&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;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;month&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;'&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="nb"&gt;Number&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;lastDay&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;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;month&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="nf"&gt;getDate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;date&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="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lastDay&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;padStart&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&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="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;date&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;A clean fix. But this is where the real problem emerged.&lt;/p&gt;

&lt;h1&gt;
  
  
  How Do You Confirm "It's&amp;nbsp;Fixed"?
&lt;/h1&gt;

&lt;p&gt;I changed one line of the prompt. But then a new challenge surfaced: how do you actually verify that the updated prompt works correctly?&lt;br&gt;
The natural answer is to re-photograph every product and check manually. But that approach has serious problems.&lt;br&gt;
If the updated prompt still fails on some cases, I have to revise it again and restart testing from scratch. To build confidence, I'd need more test cases. And since LLM outputs are probabilistic, even the same image with the same prompt can produce different results - meaning I'd need multiple test runs per product. How many times is enough?&lt;br&gt;
Repeating this entire process every time I tweak a prompt is not realistic. And this, I believe, is a universal challenge in building apps that use LLMs.&lt;br&gt;
You can unit test your code. But how do you test a prompt change?&lt;/p&gt;

&lt;h1&gt;
  
  
  Solving It with PromptProof
&lt;/h1&gt;

&lt;p&gt;That's where I used PromptProof - a platform for statistically validating LLM prompt accuracy. By integrating it into your app, you can post LLM inputs and outputs to the platform, manually annotate ground truth labels, and reuse that dataset to run prompt experiments.&lt;br&gt;
Here's how I used it:&lt;br&gt;
Post product images and extracted data from the app (photos taken just once)&lt;br&gt;
Annotate ground truth labels in PromptProof (for year-month-only labels, the last day of the month is the correct answer)&lt;br&gt;
Run experiments with both the original and revised prompts&lt;br&gt;
Compare accuracy with confidence intervals&lt;/p&gt;

&lt;h1&gt;
  
  
  The Results - and a&amp;nbsp;Surprise
&lt;/h1&gt;

&lt;p&gt;The results before the prompt change was like following.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa429fy1o2wqzla272z5t.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa429fy1o2wqzla272z5t.png" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Overall accuracy: 81% (confidence interval: 78–84%)&lt;/li&gt;
&lt;li&gt;Product name extraction: 71% (100/140 correct) [CI: 63–78%]&lt;/li&gt;
&lt;li&gt;Expiration date extraction: 57% (80/140 correct) [CI: 49–65%]&lt;/li&gt;
&lt;li&gt;Expiration type: 100% (140/140 correct) [CI: 97–100%]&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And after changed prompt was following.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvz6t62v6lbpvi36hig8b.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvz6t62v6lbpvi36hig8b.png" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Overall accuracy: 90% (confidence interval: 88–93%)&lt;/li&gt;
&lt;li&gt;Product name extraction: 57% (80/140 correct) [CI: 49–65%] ⬇️&lt;/li&gt;
&lt;li&gt;Expiration date extraction: 100% (140/140 correct) [CI: 97–100%] ⬆️&lt;/li&gt;
&lt;li&gt;Expiration type: 100% (140/140 correct) [CI: 97–100%]&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix achieved its goal. Expiration date accuracy went from 57% to 100%. The confidence intervals - [49–65%] before versus [97–100%] after - don't overlap at all. A clear, statistically significant improvement.&lt;br&gt;
But something else happened, product name accuracy dropped from 71% to 57%.&lt;br&gt;
Looking at the per-field confidence intervals tells a more nuanced story,&lt;br&gt;
Before: 71% [63–78%]&lt;br&gt;
After: 57% [49–65%]&lt;/p&gt;

&lt;p&gt;The intervals overlap slightly between 63% and 65%, so we can't call this regression statistically significant - not yet. More samples are needed to say so with confidence. That said, a 14-point decline is hard to ignore. The direction is clear even if the certainty isn't. This is a concrete hypothesis to investigate next, and precisely the kind of thing that gets missed without statistical tooling.&lt;br&gt;
Reviewing the outputs gave a clue: for well-known products, some results included the manufacturer name and others didn't. The root cause wasn't the prompt change itself - it was that the prompt had never clearly specified what "product name" should include. The experiment surfaced a gap that had always been there.&lt;br&gt;
If I had validated by manually re-photographing products, I might have caught the improvement in expiration date accuracy. But this regression would likely have gone unnoticed entirely.&lt;/p&gt;

&lt;h1&gt;
  
  
  A Bonus: Token and Latency&amp;nbsp;Insights
&lt;/h1&gt;

&lt;p&gt;Accuracy isn't the only thing PromptProof surfaces. The latency and token usage distributions turned out to be equally useful.&lt;br&gt;
Output tokens were remarkably stable - P50 of 48, max of 51 across 140 trials. That tells you exactly how much output budget to reserve when configuring max_tokens. No guesswork.&lt;br&gt;
Latency was more interesting. The first prompt showed a P50 of 1.42s but a max spike of 4.38s - a 3x gap worth knowing about. After the prompt change, the max dropped to 2.56s despite slightly longer input tokens (1.1k → 1.2k).&lt;br&gt;
When evaluating a new model, this kind of distribution data answers two practical questions before you commit:&lt;br&gt;
Is the latency acceptable for your use case? A P50 of 1.5s might be fine for a background job, but not for a real-time interaction.&lt;br&gt;
What's the minimum output token budget you actually need? Over-provisioning max_tokens wastes money; under-provisioning causes silent truncation failures.&lt;/p&gt;

&lt;p&gt;Running experiments before locking in a model means you're making that decision with data, not intuition.&lt;/p&gt;

&lt;h1&gt;
  
  
  Conclusion
&lt;/h1&gt;

&lt;p&gt;Building apps that use LLMs has made me feel that validation is genuinely harder than implementation.&lt;br&gt;
Even a single-line prompt change can have wide-ranging effects. Being able to reduce the cost of re-testing and say with statistical confidence that "this change was correct" - and to do so without friction - is what it takes to build LLM-powered apps that are reliable enough to ship.&lt;/p&gt;

&lt;h3&gt;
  
  
  Note
&lt;/h3&gt;

&lt;p&gt;PromptProof, the platform I used in this article, is a service I'm building myself. It's free to get started - if you're facing similar challenges, I'd love for you to try it out.&lt;br&gt;
👉 &lt;a href="https://promptproof.hatchandco.cloud/" rel="noopener noreferrer"&gt;PromptProof&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
