<?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: Ankur Kotwal</title>
    <description>The latest articles on DEV Community by Ankur Kotwal (@kotwal-itpro).</description>
    <link>https://dev.to/kotwal-itpro</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%2F4169688%2F69b213c9-425b-48f7-bd57-3d80f05e3fc9.png</url>
      <title>DEV Community: Ankur Kotwal</title>
      <link>https://dev.to/kotwal-itpro</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kotwal-itpro"/>
    <language>en</language>
    <item>
      <title>The Iceberg Table That Was Never Where We Wrote It</title>
      <dc:creator>Ankur Kotwal</dc:creator>
      <pubDate>Mon, 12 Oct 2026 04:21:51 +0000</pubDate>
      <link>https://dev.to/kotwal-itpro/the-iceberg-table-that-was-never-where-we-wrote-it-3c0b</link>
      <guid>https://dev.to/kotwal-itpro/the-iceberg-table-that-was-never-where-we-wrote-it-3c0b</guid>
      <description>&lt;p&gt;For the past week I've been working on &lt;a href="https://hop.apache.org/" rel="noopener noreferrer"&gt;Apache Hop&lt;/a&gt;, an open-source tool for building data pipelines. My goal was to let Hop read and write &lt;a href="https://iceberg.apache.org/" rel="noopener noreferrer"&gt;Apache Iceberg&lt;/a&gt; tables on its own, without needing a Spark cluster.&lt;/p&gt;

&lt;p&gt;That work is now merged into Hop's main branch and will be in the next release. Along the way I ran into several bugs. Most of them had one thing in common: every test passed while the bug was there. This post is about those bugs, how I found them, and what I'd check for next time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Some background
&lt;/h2&gt;

&lt;p&gt;If you haven't used Iceberg: it's a way of storing a table as a set of ordinary files, usually Parquet files, in a folder or a cloud bucket. Next to the data files, Iceberg keeps metadata files that say which data files belong to the table right now. When you add rows, Iceberg writes new data files and then a new metadata file. That last step is called a &lt;em&gt;commit&lt;/em&gt;. Until the commit happens, nobody sees the new rows.&lt;/p&gt;

&lt;p&gt;Hop could already work with Iceberg, but only when a pipeline runs on Spark. Many Hop users run pipelines on Hop's own engine instead, on a laptop or a single server. So I proposed adding Iceberg support there too. The maintainers agreed on one rule, which I liked: there should be one "Lake table input" and one "Lake table output" that work the same way on both engines, so a table written by one can be read by the other. One of them, Hans Van Akelyen, put it as "design once, run everywhere".&lt;/p&gt;

&lt;p&gt;I split the work into four pull requests: moving the existing code into a shared plugin, reading, writing, and docs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: The table was in the temp folder
&lt;/h2&gt;

&lt;p&gt;If both engines are supposed to work on the same tables, I wanted a test that proves it. So I wrote one: write a table with the Spark engine, then read it with Hop's own engine.&lt;/p&gt;

&lt;p&gt;It failed right away. Hop's engine said there was no table at that path.&lt;/p&gt;

&lt;p&gt;That was odd, because Spark's own tests, which write a table and then read it back, all passed. So I went looking for the files. The table did exist. It just wasn't where we wrote it. A pipeline told to write to&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/tmp/hop-it-iceberg/0005-table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;had actually written to&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$TMPDIR/hop-iceberg-path-catalog-warehouse/file:/tmp/hop-it-iceberg/0005-table/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a folder inside the system's temp directory, with a subfolder literally named &lt;code&gt;file:&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it happened.&lt;/strong&gt; Iceberg tables usually live in a &lt;em&gt;catalog&lt;/em&gt;, a service that maps table names to folders. Iceberg can also work with a plain folder path. But in Spark, it only treats your text as a path if you read the table in one particular way. Hop was passing the path in a way that Spark treated as a table &lt;em&gt;name&lt;/em&gt; instead. So the catalog did what catalogs do with a name: it created the table in its own storage folder, which happened to be in the temp directory.&lt;/p&gt;

&lt;p&gt;Reading used the same name, so it looked in the same wrong place and found the table. That's why every write-then-read test passed. The data was fine until the operating system cleaned out its temp folder.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix.&lt;/strong&gt; I reported this as &lt;a href="https://github.com/apache/hop/issues/8794" rel="noopener noreferrer"&gt;issue #8794&lt;/a&gt;. The fix, &lt;a href="https://github.com/apache/hop/pull/8795" rel="noopener noreferrer"&gt;#8795&lt;/a&gt;, gives each folder its own small catalog that stores tables in that folder, so the table now lands exactly at the path you asked for.&lt;/p&gt;

&lt;p&gt;Matt Casters, who started Hop, reviewed the fix twice before merging it. Both times he found cases I had missed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a table at the top of a Windows drive, like &lt;code&gt;C:\orders&lt;/code&gt;, was rejected;&lt;/li&gt;
&lt;li&gt;two ways of writing the same folder, like &lt;code&gt;file:/x&lt;/code&gt; and &lt;code&gt;file:///x&lt;/code&gt;, or &lt;code&gt;C:&lt;/code&gt; and &lt;code&gt;c:&lt;/code&gt;, were treated as two different places;&lt;/li&gt;
&lt;li&gt;a path with a space in it ended up in a folder called &lt;code&gt;my%20lake&lt;/code&gt; instead of &lt;code&gt;my lake&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of those has a test now.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I learned.&lt;/strong&gt; A test that writes data and reads it back only proves the system can find its own data again. It doesn't prove the data is where you think it is. The test that would have caught this is tiny: after writing, check that the table's files exist at the path you gave.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: Every test passed, but every real write failed
&lt;/h2&gt;

&lt;p&gt;My new code had plenty of unit tests, including pipelines that write with several copies in parallel and pipelines that fail on purpose to check that nothing gets committed. They all passed.&lt;/p&gt;

&lt;p&gt;Then I built a full Hop install and ran the same pipeline the way a user would. Every write failed with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Format model is not registered for format PARQUET and type interface org.apache.iceberg.data.Record
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why it happened.&lt;/strong&gt; When Iceberg starts up, it looks for the code that knows how to write Parquet files and registers it. It does this once. To find that code, it asks the current thread which set of classes it can see.&lt;/p&gt;

&lt;p&gt;In a unit test, everything is in one place, so Iceberg finds what it needs. In a real Hop install, each plugin has its own separate set of classes, and the threads that run a pipeline don't see inside the plugins. So Iceberg looked, found nothing, and from then on believed there was no Parquet writer at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix.&lt;/strong&gt; A small helper that points the thread at the plugin's classes whenever Hop calls into Iceberg.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I learned.&lt;/strong&gt; If your code runs inside a plugin system, some of your tests have to run inside that plugin system too. Unit tests alone can't show this kind of problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 3: Some rows twice, some not at all
&lt;/h2&gt;

&lt;p&gt;Hop can run a step as several copies in parallel to go faster. For reading, that means splitting the table's files between the copies, so each piece is read by exactly one copy.&lt;/p&gt;

&lt;p&gt;My first version split the work by position in a list: copy 1 takes pieces 1, 3, 5 and so on, copy 2 takes 2, 4, 6. My test expected 1,200 rows and got 1,202. Some rows came through twice and some were missing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it happened.&lt;/strong&gt; Each copy asks Iceberg for the list of pieces on its own. Iceberg builds that list by reading several files at the same time, so the order can be different every time you ask. Copy 1 and copy 2 got the list in different orders, so "every other piece" meant different pieces for each of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix.&lt;/strong&gt; Instead of using the position in the list, each piece is assigned to a copy based on its file name and where it starts in the file. Every copy works out the same answer no matter what order the list comes in.&lt;/p&gt;

&lt;p&gt;Bart Maertens from the Hop project committee found the next problem in the same area while reviewing this. Each copy was also choosing on its own which version of the table to read. If someone wrote to the table in between, two copies could read two different versions. Now all copies agree on one version first. Before merging, Bart also tested the whole thing himself against a table in S3-style cloud storage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I learned.&lt;/strong&gt; Don't depend on an order that nobody promised you, especially when several processes need to agree with each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting the commit right
&lt;/h2&gt;

&lt;p&gt;This part starts with a design decision, and then a few bugs in the details.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The decision.&lt;/strong&gt; When a pipeline writes to a table, when should the commit happen? If each parallel copy committed on its own, someone reading the table could see half of a run. If the commit happened as soon as the write step was done, a later step in the same pipeline could still fail, and the table would already contain data from a failed run.&lt;/p&gt;

&lt;p&gt;So Hop commits only once, after the whole pipeline has finished without errors. All the files written in that run become one new version of the table. If anything fails, the files are deleted and the table stays as it was. If the table didn't exist yet, it's created in that same commit, so a failed run doesn't even leave an empty table behind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The details.&lt;/strong&gt; Matt reviewed the write pull request, &lt;a href="https://github.com/apache/hop/pull/8819" rel="noopener noreferrer"&gt;#8819&lt;/a&gt;, three times in one day. Each round found something real.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Two writers, one lost.&lt;/em&gt; For tables stored as a plain folder, a commit means writing the next numbered metadata file, like &lt;code&gt;v5.metadata.json&lt;/code&gt;. If two pipelines write at the same moment, only one of them may get number 5. The other has to notice and try again. I used a file "move" for this, but as Matt pointed out, that move quietly replaces the file if it already exists. Both pipelines would think they had succeeded, and one of their changes would be gone. I wrote a test with several writers at once, and on the old code, changes really did go missing. Now, on a local disk, the commit uses a file operation that fails if the file is already there. On cloud storage like S3 there's no such operation, so the docs say to keep to one writer at a time there, or to use a catalog.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"Finished" too early.&lt;/em&gt; Hop marks a pipeline as finished before it gets to my commit code. A scheduler watching for "finished" could start the next job before the new data was actually in the table. Now the commit runs first. The test for this watches the pipeline's status from a separate thread while three copies write 3,000 rows. On the old code, it saw "finished" before the data was there on every single run.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Overwriting an empty table.&lt;/em&gt; When a pipeline overwrites a table, it first checks that nobody else changed the table since the run started. But that check was skipped when the table was still empty. So if someone added the first rows during an overwrite, those rows would be wiped out. Now the check always runs.&lt;/p&gt;

&lt;p&gt;None of these show up in a test that runs one pipeline at a time. All of them could lose someone's data.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd check next time
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Check where the data actually is&lt;/strong&gt;, not only that you can read it back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test in the real setup&lt;/strong&gt;: a full install with its plugins, not only unit tests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't trust an order&lt;/strong&gt; that the API doesn't promise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Commit when the whole job is done&lt;/strong&gt;, and report "done" only after the commit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test with several writers at once&lt;/strong&gt; if your code will ever have several writers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And get a careful reviewer if you can. Matt Casters and Bart Maertens found problems I would not have found on my own. Thanks to both of them.&lt;/p&gt;

&lt;p&gt;The code is in Apache Hop's main branch: reading in &lt;a href="https://github.com/apache/hop/pull/8802" rel="noopener noreferrer"&gt;#8802&lt;/a&gt;, writing in &lt;a href="https://github.com/apache/hop/pull/8819" rel="noopener noreferrer"&gt;#8819&lt;/a&gt;, and the docs in &lt;a href="https://github.com/apache/hop/pull/8859" rel="noopener noreferrer"&gt;#8859&lt;/a&gt;, which is still being reviewed.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kotwal-itpro.github.io/2026/10/11/iceberg-table-never-where-we-wrote-it/" rel="noopener noreferrer"&gt;my blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>iceberg</category>
      <category>dataengineering</category>
      <category>opensource</category>
      <category>java</category>
    </item>
    <item>
      <title>Five Ways to Run the Same AI Job, Three Kinds of Mistakes, and What Broke at 100,000 Records</title>
      <dc:creator>Ankur Kotwal</dc:creator>
      <pubDate>Fri, 09 Oct 2026 18:18:23 +0000</pubDate>
      <link>https://dev.to/kotwal-itpro/five-ways-to-run-the-same-ai-job-three-kinds-of-mistakes-and-what-broke-at-100000-records-380j</link>
      <guid>https://dev.to/kotwal-itpro/five-ways-to-run-the-same-ai-job-three-kinds-of-mistakes-and-what-broke-at-100000-records-380j</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://kotwal-itpro.github.io/2026/10/09/five-ways-three-kinds-of-mistakes/" rel="noopener noreferrer"&gt;kotwal-itpro.github.io&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Two days ago I wrote about &lt;a href="https://kotwal-itpro.github.io/2026/10/07/same-instructions-opposite-mistakes/" rel="noopener noreferrer"&gt;giving two AI models the same instructions&lt;/a&gt; and watching them get things wrong in opposite ways. Since then I've added two more ways of running the job, made it 100 times bigger, and run it again on different days. Here's what changed, and what didn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup, quickly
&lt;/h2&gt;

&lt;p&gt;Same job as before. I have fake customer support chats, and for each one the AI has to say which product it's about, what kind of problem it is, how the customer feels, and whether they asked to be contacted again. I wrote the chats, so I know every right answer.&lt;/p&gt;

&lt;p&gt;This time I ran it five ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OpenAI&lt;/strong&gt; (gpt-4.1-mini)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google Gemini&lt;/strong&gt; (gemini-3.5-flash-lite)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anthropic Claude&lt;/strong&gt; (Haiku 5.5), with its normal settings&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude again&lt;/strong&gt;, with its "thinking" turned off&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A model I ran myself&lt;/strong&gt;: Qwen2.5-7B, an open model, on a GPU I rented for a few minutes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first four are "batch" services. You send everything at once and collect the answers later, for about half the usual price. The last one has no queue at all. It's just a computer doing the work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three kinds of mistakes
&lt;/h2&gt;

&lt;p&gt;All five got the product and the problem type right every time. Nearly all of them got the follow-up question right too, once I'd explained it properly (that was the big lesson last time).&lt;/p&gt;

&lt;p&gt;The "how does the customer feel" question is where they split, into three groups:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Too negative.&lt;/strong&gt; OpenAI (93% right) and my self-hosted model (85% right). A customer writes "My laptop stopped working after three days. Let me know what you need from me." That's a calm, helpful message about a problem. Both called the customer unhappy. My own model did this twice as often, and sometimes even turned happy customers into unhappy ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Too forgiving.&lt;/strong&gt; Gemini (82% right). "This is really frustrating" came back as neutral.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Depends on one setting.&lt;/strong&gt; Claude scored about the same both ways, 89% and 91%. With thinking on, its mistakes were split about evenly between too harsh and too forgiving. With thinking off, almost all of them were too harsh, like OpenAI's. Same model, same instructions, one switch, and the balance of its mistakes changed. The two settings gave different answers on about 1 chat in 9.&lt;/p&gt;

&lt;p&gt;If you take one thing from this post: &lt;strong&gt;a score doesn't tell you which way a model is wrong.&lt;/strong&gt; If something downstream counts unhappy customers, two models with similar scores can give you very different counts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it myself: fast and cheap, with a catch
&lt;/h2&gt;

&lt;p&gt;The self-hosted model surprised me. Once it was set up, it did all 1,000 chats in &lt;strong&gt;29 seconds&lt;/strong&gt;. The hosted services took 3 to 10 minutes, because you wait in their queue. It also cost about &lt;strong&gt;$0.004 per 1,000 chats&lt;/strong&gt; in GPU time, about a tenth of the cheapest hosted option.&lt;/p&gt;

&lt;p&gt;The catch is the setup. Installing the software, downloading the model and getting it ready took about 8 minutes on the rented GPU, which cost more than the actual work. And it was the least accurate of the five on feelings. It's also an older, smaller model than the hosted ones, so that isn't the last word on self-hosting.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened at 100,000 records
&lt;/h2&gt;

&lt;p&gt;Then I made the job 100 times bigger: 100,000 chats, on each of the three batch services.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nothing went missing.&lt;/strong&gt; All three sent back all 100,000 answers. None needed a retry, and the scores matched the smaller runs almost exactly. I'd half expected the trouble to start here. It didn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The waiting changed a lot.&lt;/strong&gt; Gemini did it in 10 pieces of 10,000, about 4 minutes each, so 45 minutes in total. Claude took all 100,000 in one go and needed 2 hours and 44 minutes. OpenAI was the odd one. Its pieces took 14 minutes early in the afternoon, then slowed to almost an hour and a half by the evening. Same job, same size, six times slower depending on when I sent it.&lt;/p&gt;

&lt;p&gt;The price per 1,000 chats didn't change with size: about 9 cents on OpenAI and Gemini, 5 cents on Claude.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The only real failure was on my side.&lt;/strong&gt; Eight hours into the OpenAI run, my internet connection dropped while uploading the ninth piece, and the job stopped. This is exactly what I built the tool for. It keeps a record of what's done, so when I ran the same command again it picked up the last 20,000 chats and didn't resend, or pay for, the 80,000 that were already finished. It now also waits and retries when the connection drops, instead of giving up.&lt;/p&gt;

&lt;p&gt;One thing went wrong before any work started. I sent Gemini all 100,000 chats as one job. It took five minutes to upload, then Gemini said:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You exceeded your current quota, please check your plan and billing details.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That sounds like a billing problem. It wasn't. When I sent the same work in pieces of 10,000, one after another, every piece went straight through. The real problem was that one job was too big for how much work my account can have waiting. The message just didn't say so.&lt;/p&gt;

&lt;p&gt;That one changed my tool. It now recognizes a "queue full" answer, waits or sends smaller pieces, and doesn't count it as a failed attempt for the records involved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does it give the same answer twice?
&lt;/h2&gt;

&lt;p&gt;This was the question I most wanted answered. I sent the exact same 1,000 chats, with the same instructions, to each service on three days in a row: Wednesday, Thursday and Friday.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OpenAI&lt;/strong&gt; gave the same answer all three days on &lt;strong&gt;99.3%&lt;/strong&gt; of chats. Only 7 changed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude&lt;/strong&gt; did it on &lt;strong&gt;94.4%&lt;/strong&gt;. 56 changed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gemini&lt;/strong&gt; did it on &lt;strong&gt;87.5%&lt;/strong&gt;. 125 changed, about 1 in 8, even with its randomness setting turned all the way down.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the strange part. The overall scores barely moved from day to day: Gemini's "how does the customer feel" score was 82%, 81% and 81%. But underneath, a different set of chats was right each day. Every single chat that changed was answered correctly on one day and wrongly on another. The services wobble on the borderline cases, and which side a case lands on changes from day to day.&lt;/p&gt;

&lt;p&gt;If you compare this week's numbers with last week's, some of the change is the model, not your customers. Know how big that wobble is before you read anything into a small shift.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone starting out
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Explain every field in one plain sentence.&lt;/strong&gt; It took one question from 31% right to nearly 100%.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check every record off.&lt;/strong&gt; Don't trust a job that says "done". Count what came back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Find out if your model "thinks".&lt;/strong&gt; Thinking uses up the same room as the answer. Claude ran out of room on 1% of chats until I turned it off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Send big jobs in pieces, and keep a record of what's done.&lt;/strong&gt; Don't take "quota exceeded" at face value, and expect the network to drop at least once in a long job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't expect the same answer twice.&lt;/strong&gt; Even with randomness turned down, some answers change between runs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Before switching models, compare the mistakes, not the scores.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I've written these up properly in a &lt;a href="https://github.com/kotwal-itpro/steadybatch/blob/main/docs/GUIDE.md" rel="noopener noreferrer"&gt;guide&lt;/a&gt;. The tool is now on PyPI (&lt;code&gt;pip install steadybatch&lt;/code&gt;), and the code, the fake chats and every result are on GitHub at &lt;a href="https://github.com/kotwal-itpro/steadybatch" rel="noopener noreferrer"&gt;kotwal-itpro/steadybatch&lt;/a&gt;. If you want to cite it, it now has a DOI: &lt;a href="https://doi.org/10.5281/zenodo.23221956" rel="noopener noreferrer"&gt;10.5281/zenodo.23221956&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>machinelearning</category>
      <category>python</category>
    </item>
    <item>
      <title>I Gave Two AI Models the Same Instructions. They Got It Wrong in Opposite Ways.</title>
      <dc:creator>Ankur Kotwal</dc:creator>
      <pubDate>Wed, 07 Oct 2026 19:35:30 +0000</pubDate>
      <link>https://dev.to/kotwal-itpro/i-gave-two-ai-models-the-same-instructions-they-got-it-wrong-in-opposite-ways-5fie</link>
      <guid>https://dev.to/kotwal-itpro/i-gave-two-ai-models-the-same-instructions-they-got-it-wrong-in-opposite-ways-5fie</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://kotwal-itpro.github.io/2026/10/07/same-instructions-opposite-mistakes/" rel="noopener noreferrer"&gt;kotwal-itpro.github.io&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A lot of teams now use AI models to read large piles of text: support tickets, reviews, emails. The cheapest way to do that is a "batch" job. You put all your requests in one file, send it off, and pick up the answers a few hours later. You pay about half the normal price.&lt;/p&gt;

&lt;p&gt;I wanted to know how reliable these batch jobs really are, so I started a small open project to test them. I began with two providers, OpenAI and Google's Gemini. Here's what happened in the first round.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I tested
&lt;/h2&gt;

&lt;p&gt;I made 1,000 fake customer support chats. Each one is a short conversation like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Customer: I was charged twice for my router. Thanks.&lt;br&gt;&lt;br&gt;
Agent: Sorry about that. Could you confirm your order number?&lt;br&gt;&lt;br&gt;
Customer: Order number is A10012.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For every chat, the model had to answer four questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Which product is it about?&lt;/li&gt;
&lt;li&gt;What kind of problem is it? (billing, shipping, broken product, or a "how do I" question)&lt;/li&gt;
&lt;li&gt;How does the customer feel?&lt;/li&gt;
&lt;li&gt;Did the customer ask to be contacted again?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Because I wrote the chats myself, I know the right answer for every one. So I can check not only whether the model replied, but whether it was right.&lt;/p&gt;

&lt;p&gt;I sent the same 1,000 chats to OpenAI (gpt-4.1-mini) and to Gemini (gemini-3.5-flash-lite).&lt;/p&gt;

&lt;h2&gt;
  
  
  The good news: nothing broke
&lt;/h2&gt;

&lt;p&gt;Both services sent back all 1,000 answers. Nothing went missing, nothing came back garbled, and I didn't have to retry anything. Each job took three or four minutes. It cost about 8 or 9 cents for the whole batch on either one.&lt;/p&gt;

&lt;p&gt;I expected at least a few problems, so this surprised me a little. My guess is that the trouble starts with much bigger jobs. That's what I'll test next.&lt;/p&gt;

&lt;h2&gt;
  
  
  My first try made both models look bad
&lt;/h2&gt;

&lt;p&gt;In my very first small test, I only gave the models the names of the four things I wanted, nothing more.&lt;/p&gt;

&lt;p&gt;They nailed the first two: product and problem type were 100% right. But the follow-up question came back right only 31% of the time, on both models.&lt;/p&gt;

&lt;p&gt;When I looked at the answers, the models weren't really wrong. They had answered a different question. I meant "did the customer ask us to get back to them?" They read it as "does this ticket need more work?" And of course every open problem needs more work, so they said yes to almost everything.&lt;/p&gt;

&lt;p&gt;So I added one plain sentence explaining each question. That's all. The follow-up answers went from 31% right to almost 100% right, on both models.&lt;/p&gt;

&lt;p&gt;That was the biggest lesson of the whole test. When a model seems to get something wrong, first check whether you actually told it what you meant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then they made opposite mistakes
&lt;/h2&gt;

&lt;p&gt;Even with the explanations, the "how does the customer feel" question was still tricky. My instructions said: judge how the customer feels about the product, and don't count the problem itself as a bad feeling.&lt;/p&gt;

&lt;p&gt;OpenAI got this right 93% of the time, and Gemini 82%. But the scores aren't the interesting part. The interesting part is how each one got it wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OpenAI was too negative.&lt;/strong&gt; When a customer politely reported a problem, like "I was charged twice. Thanks.", it often called them unhappy. It reacted to the problem, even though I'd said not to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gemini was too forgiving.&lt;/strong&gt; When a customer said "This is really frustrating", it often called them neutral. It followed my "don't count the problem" rule so strictly that it ignored real frustration too.&lt;/p&gt;

&lt;p&gt;Same instructions, opposite mistakes.&lt;/p&gt;

&lt;p&gt;Why does this matter? Say you count unhappy customers every week. With one model you'd count too many, and with the other you'd count too few. If you switch models, your numbers move, and it isn't because your customers changed. So when you compare models, don't just compare the scores. Look at which answers each one gets wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  One real glitch
&lt;/h2&gt;

&lt;p&gt;The only actual failure happened before any work started. Gemini's list of models said one older model was available. When I sent my batch job to it, Gemini refused, saying that model was "no longer available to new users". So the list and the batch service didn't agree.&lt;/p&gt;

&lt;p&gt;That failed attempt also left my uploaded file sitting on Google's servers. I've changed my tool so it cleans up after itself when that happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Over the next couple of days I'll run the exact same job again, to see whether the models give the same answers each time. After that I'll try much bigger jobs, 100,000 chats and more, and add more providers.&lt;/p&gt;

&lt;p&gt;Everything is public: the code, the fake chats, and every result. It's all on GitHub at &lt;a href="https://github.com/kotwal-itpro/steadybatch" rel="noopener noreferrer"&gt;kotwal-itpro/steadybatch&lt;/a&gt;, including a &lt;a href="https://github.com/kotwal-itpro/steadybatch/blob/main/docs/FINDINGS.md" rel="noopener noreferrer"&gt;log of everything I found&lt;/a&gt;, my own early mistake included. If you run batch jobs like this and have seen something strange, I'd love to hear about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Update, October 7: I added Claude
&lt;/h2&gt;

&lt;p&gt;After writing this, I ran the same 1,000 chats through Anthropic's Claude, using its small model, Haiku 5.5. Two things stood out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It was the first model to lose an answer.&lt;/strong&gt; Out of the box, this model decides for itself when to stop and "think" before answering. Usually it doesn't. But on 10 chats it thought for so long that it ran out of room before finishing its answer. Some answers came back empty, and some were cut off halfway. My tool caught every one and asked again. Nine came back fine on the second or third try. One never did, so this run ended at 999 out of 1,000.&lt;/p&gt;

&lt;p&gt;The room I'd given it was about eight times what a normal answer needs. It was the same limit I'd used for OpenAI and Gemini without any trouble. If you use a model like this, either give it much more room than you think it needs, or turn the thinking off.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One setting changed its mistakes.&lt;/strong&gt; So I ran it again with thinking turned off. This time all 1,000 came back on the first try, in half the time: about 5 minutes instead of 10.&lt;/p&gt;

&lt;p&gt;On "how does the customer feel", Claude scored about the same both times, 89% and 91%. That's between OpenAI and Gemini. But look at how it got things wrong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;With thinking on, its mistakes were split about evenly.&lt;/strong&gt; Sometimes too forgiving ("How do I reset my phone? This is really frustrating." came back as neutral), sometimes too harsh.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;With thinking off, almost all of them were too harsh, like OpenAI.&lt;/strong&gt; "My laptop stopped working after three days. Let me know what you need from me." came back as unhappy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same model, same instructions, one setting changed, and the balance of its mistakes changed with it. The two runs disagreed on about 1 chat in 9.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Correction, Oct 9: an earlier version said that with thinking on, Claude was "mostly too forgiving". Counting every mistake, they were about evenly split.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That makes the main point of this post stronger. A score can stay the same while the answers underneath it change. If you depend on the counts, check which answers move, not just the score.&lt;/p&gt;

&lt;p&gt;It was also the cheapest of the three: about 5 cents per 1,000 chats, against 8 to 9 cents for OpenAI and Gemini.&lt;/p&gt;

&lt;p&gt;Next up: the same test on a model I run myself on a rented GPU, and then much bigger jobs.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>machinelearning</category>
      <category>python</category>
    </item>
  </channel>
</rss>
