<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>John Shahbazian</title>
    <description>Principal engineer building applied AI products, cloud platforms, and production software.</description>
    <link>https://jshahbazi.github.io</link>
    <atom:link href="https://jshahbazi.github.io/feed.xml" rel="self" type="application/rss+xml" />
    
      <item>
        <title>Testing SubQ Snap on Two Jobs: Picking Tools and Grading Answers</title>
        <description>&lt;p&gt;&lt;a href=&quot;https://docs.subq.ai/&quot;&gt;SubQ Snap&lt;/a&gt; is a hosted decision model: you send it some context, instructions, and a fixed set of options, and it returns one option plus a probability for each. No prose, nothing to parse. That is the same shape as Jev, the open JevK5 models, and OpenAI’s Decisions API, all of which I have tested here as answer graders. I ran Snap through two jobs: having an agent pick the right tool for a request, and grading a model’s answer against a known fact. It did very well at one and not the other.&lt;/p&gt;

&lt;!--more--&gt;

&lt;p&gt;Short answer: it was the best tool picker we have measured, and a middling answer grader. Every tool decision in a 250-case test came back right, with a median response time of 155 ms and a total cost under a cent. On the 100 hand-graded answers, it agreed with the human labels 84% of the time without the source document and 86% with it. JevK5-4B, our preferred local grader, scored 90% and 88% on the exact same requests. The rest of this post is about why I think both of those things are true at once, and what I am actually going to do with them.&lt;/p&gt;

&lt;p&gt;A note on how the tests were run, since it matters for what follows. Nothing was changed for Snap. We kept the datasets and request formats from earlier runs, added Snap as one more model, and read every competitor’s number from its saved, audited record. No prompt edits after seeing scores, no retries to nudge a result. Both experiments passed independent audits.&lt;/p&gt;

&lt;h2 id=&quot;the-tool-picking-test&quot;&gt;The tool-picking test&lt;/h2&gt;

&lt;p&gt;The dataset is the public &lt;a href=&quot;https://huggingface.co/datasets/pyuvraj/bfcl-v4-jev-routing/tree/9cd92d8c7789eb390d271adbd7312bbb3e3657cf&quot;&gt;BFCL V4 Live routing adaptation&lt;/a&gt;. It has 250 requests. In 150 of them the model sees several tools and one is the right one; in fifty it sees exactly one tool, and that tool applies; in the last fifty no offered tool applies at all. For each request the model gets the user’s message and the tool descriptions and picks from four to seven options, three of which are non-tool actions: say no tool is needed, ask a clarifying question, or say it cannot help.&lt;/p&gt;

&lt;p&gt;Here are the rows that matter from a fourteen-model comparison:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Model&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;Agreement&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;Unnecessary tool calls (of 50)&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;Median response time&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;SubQ Snap Preview&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;100.0%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;0&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;155 ms&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;JevK5-9B&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;98.8%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;3&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;239 ms&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Tura-S1-4B&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;98.4%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;4&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;373 ms&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;JevK5-4B MLX&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;97.6%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;2&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;168 ms&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;OpenAI Decisions, gpt-6-luna&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;94.8%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;0&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;162 ms&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Jeff 0.8B&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;94.8%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;1&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;51 ms&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Snap picked the right tool on all 200 cases where a tool applied, and never reached for a tool on the fifty where none did. That is what the 100% means, and it is worth being precise about, because there is a looser and a stricter way to read those fifty negative cases.&lt;/p&gt;

&lt;p&gt;The dataset accepts any of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;no_tool&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;clarify&lt;/code&gt;, or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cannot_answer&lt;/code&gt; on a negative case. Snap chose &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;no_tool&lt;/code&gt;, the canonical label, on 27 of them and one of the other two permitted actions on 23. If you insist on the canonical label, Snap’s agreement drops to 90.8%, and Tura edges it at 92.0%. So “perfect” here means it never called a tool it should not have and never missed one it should have. It does not mean it always chose the best way to say no. If your product cares whether the agent asks a follow-up question versus answering directly versus declining, that is a separate test and this one does not cover it.&lt;/p&gt;

&lt;p&gt;The margins are also smaller than the table makes them look. Snap beat JevK5-4B MLX by six judgments, Tura by four, and the 9B by three. Against the 4B that is +2.4 percentage points with a paired 95% interval of +0.8 to +4.4; against the 9B the interval includes zero. These intervals are descriptive and not adjusted for the fact that I am making several comparisons at once. The catalogues are small and public, so I cannot rule out training overlap, and the test says nothing about filling in tool arguments, executing the call, large tool inventories, or finishing an actual multi-step task. Still: every applicable tool, zero wrong calls, 155 ms, fractions of a cent. That earned a second test.&lt;/p&gt;

&lt;h2 id=&quot;the-grading-test&quot;&gt;The grading test&lt;/h2&gt;

&lt;p&gt;Human100 is the set I keep coming back to. It is 100 cases from one document corpus, each with a question, a reference fact, a candidate answer, the entities that have to appear, and a rubric. A person graded each one: 67 accepted, 33 rejected. The judge returns &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;correct&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;incorrect&lt;/code&gt;, or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;partial&lt;/code&gt;; only &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;correct&lt;/code&gt; counts as accepting the answer. We run two versions of each case, one with just the fact and rubric and one that also includes the original source text, and we run each version twice to check that the model is stable. For Snap that was 400 judgments. The repeats stay clustered by case in the statistics; they do not turn 100 cases into 400.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Model&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;Without source&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;With source&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;Median response time (without / with)&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Jev&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;90.0%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;88.5%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;438 / 461 ms&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;JevK5-4B MLX&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;90.0%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;88.0%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;176 / 923 ms&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;JevK5-4B Q8&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;89.0%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;88.0%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;162 / 998 ms&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Decider-4B v2.1&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;87.0%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;88.0%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;167 / 1,212 ms&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;SubQ Snap Preview&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;84.0%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;86.0%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;154 / 211 ms&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;OpenAI Decisions, gpt-6-luna&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;83.0%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;82.0%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;135 / 136 ms&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Snap marked eight of the 33 human-rejected answers as correct in both versions. It marked eight of the 67 human-approved answers wrong without the source text and six with it. Both repeat passes returned identical verdicts on every case.&lt;/p&gt;

&lt;p&gt;The error pattern is more interesting than the totals. Without the source text, Snap and JevK5-4B MLX let through the same number of bad answers. The whole six-point gap comes from Snap rejecting good ones: eight distinct cases versus two. With the source text, Snap let through one fewer bad answer than MLX and rejected three more good ones. Those two kinds of mistakes cost different things in a real pipeline, and a single agreement percentage hides which one you are buying.&lt;/p&gt;

&lt;p&gt;I would not read the gap as a ranking, either. Snap minus MLX is −6 points without the source, with a 95% interval of −12 to 0, and −2 points with it, with an interval of −9 to +5. On a reused set of 100 cases that is “probably a bit worse, maybe not”. It is also not a reason to switch, which is the question I actually had.&lt;/p&gt;

&lt;h2 id=&quot;why-the-same-model-would-be-great-at-one-and-ordinary-at-the-other&quot;&gt;Why the same model would be great at one and ordinary at the other&lt;/h2&gt;

&lt;p&gt;My working guess is that these two jobs reward different kinds of judgment, and the shared interface disguises that.&lt;/p&gt;

&lt;p&gt;Picking a tool from a small catalogue is mostly matching: does this request line up with one of these capabilities, or with none of them? Grading an answer is checking whether specific facts, entities, and conditions survived intact, and deciding whether what got dropped was important enough to fail the answer.&lt;/p&gt;

&lt;p&gt;A toy version: deciding that a request should go to the returns-policy lookup tool is easy. Grading the answer that comes back means noticing that “30 days after purchase” is not the same as the reference policy’s “30 days after delivery”. The answer is relevant, fluent, on topic, and wrong. A typed-choice API does not do that noticing for the model; the model still has to.&lt;/p&gt;

&lt;p&gt;I want to be clear that this is a hypothesis. We did not isolate anything about Snap’s architecture or training data, the two datasets differ in domain and in how they are scored, and tool selection presumably gets much harder as catalogues grow and tools start to overlap. The honest version of the claim is: on these two tests, the ordering flipped, and I have a plausible story for why.&lt;/p&gt;

&lt;h2 id=&quot;speed-and-money&quot;&gt;Speed and money&lt;/h2&gt;

&lt;p&gt;Even where Snap lost on accuracy, it was quick. With the source text included, its median response time was 211 ms against 923 ms for the JevK5-4B MLX recipe we measured, about 4.37 times faster. Those requests ran roughly 2,300 to 4,300 input tokens, which is real document handling but well short of the long context SubQ advertises. The local timings were on an M5 Max with 128 GB of memory, in different sessions from the hosted ones, so this compares two practical setups rather than isolating anything about the model, and it says nothing about throughput under load.&lt;/p&gt;

&lt;p&gt;At SubQ’s &lt;a href=&quot;https://subq.ai/&quot;&gt;published pricing&lt;/a&gt; of $0.05 per million input tokens with free output, the entire tool-picking experiment including controls cost an estimated $0.002835. Human100 including controls was $0.032868. Both together: about 3.6 cents. I like that number mostly because it means trying a new model on our full test set is now a rounding error, whether or not the model wins.&lt;/p&gt;

&lt;p&gt;One thing that did not pan out was using Snap’s probabilities as a filter. We have a standing rule from earlier posts: if a grader’s top probability clears 0.95, let the verdict stand, otherwise hand the case to a larger model. That screen kept only 21% of the cases without the source text and 12% with it. The 21% still included a wrong answer marked correct. The 12% was error-free but amounted to twelve unique cases, which is nowhere near enough to certify a production cutoff. We did not fit a new threshold and nothing in production changed.&lt;/p&gt;

&lt;h2 id=&quot;where-this-leaves-things&quot;&gt;Where this leaves things&lt;/h2&gt;

&lt;p&gt;For the next round of agent tool-selection work, Snap goes in as the hosted model to beat. For grading answers against documents, I am going with JevK5-4B MLX, same as before, and nothing here moved me off it. What would change that is a test on new cases from more than one corpus, with the cost of each kind of error in our pipeline written down first.&lt;/p&gt;

&lt;p&gt;The general point is that a shared interface does not make the tasks behind it interchangeable. Snap, JevK5, and the OpenAI endpoint all take the same kind of request and return the same kind of answer, and the ordering between them still flipped from one job to the other. A result on one of these tasks says little about the other; each needs its own test.&lt;/p&gt;
</description>
        <pubDate>Mon, 12 Oct 2026 00:00:00 -0400</pubDate>
        <link>https://jshahbazi.github.io/testing-subq-snap-on-two-jobs</link>
        <guid isPermaLink="true">https://jshahbazi.github.io/testing-subq-snap-on-two-jobs</guid>
      </item>
    
      <item>
        <title>Testing OpenAI&apos;s Decisions API Against Jev and JevK5-4B</title>
        <description>&lt;p&gt;&lt;img src=&quot;/images/openai-decisions-human100.png&quot; alt=&quot;Human100 results for nine graders, with the OpenAI Decisions row outlined in red. OpenAI Decisions agreed with the human labels 83% / 82% on J0 / E1 with 16 / 18 and 14 / 22 false accepts / rejects over both passes and a median response time of 135 / 136 ms. Jev scored 90% / 88.5%, JevK5-4B 89% / 88%, Decider v2 86% / 86%, Jeeves 84% / 85%, JevK5-9B 82% / 88%, Jeff 81% / 72%, Clef-flash 80% / 79%, Strands Decider 73% / 68%.&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Setup: Over the past few weeks I have written up &lt;a href=&quot;/evaluating-jev-as-an-eval-grader&quot;&gt;TypeSafe’s Jev as that grader&lt;/a&gt;, &lt;a href=&quot;/cutting-answer-grading-cost-with-a-first-pass-grader&quot;&gt;Jev as a first-pass filter in front of Claude&lt;/a&gt;, and &lt;a href=&quot;/testing-jevk5-4b-as-a-free-local-jev-alternative&quot;&gt;six open models that copy Jev’s interface&lt;/a&gt;. Until this week Jev was the only hosted service of its kind from a vendor we would consider. OpenAI has now released its &lt;a href=&quot;https://developers.openai.com/api/docs/guides/decisions&quot;&gt;Decisions API&lt;/a&gt;, which does the same job: you send it input and a typed question, and it returns a decision and probabilities instead of prose. We ran it through the same 100 hand-graded cases as everything else.&lt;/p&gt;

&lt;!--more--&gt;

&lt;h2 id=&quot;short-version&quot;&gt;Short version&lt;/h2&gt;

&lt;p&gt;OpenAI Decisions with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gpt-6-luna&lt;/code&gt; agreed with the human accept/reject labels 83% of the time without the source text and 82% with it. Our Jev run scored 90% and 88.5% on the same cases; JevK5-4B, the open model we picked last time, scored 89% and 88%. The OpenAI result is faster than anything we have measured (median 135 ms including the network round trip) and cheap (all 400 judgments cost about $0.063 at list price), but it rejected three to four times as many human-approved answers as Jev or JevK5-4B, and it let through about as many bad ones. It is a useful hosted speed and cost result. It does not replace Jev or JevK5-4B for this grading task.&lt;/p&gt;

&lt;h2 id=&quot;background&quot;&gt;Background&lt;/h2&gt;

&lt;p&gt;The Decisions API is a separate endpoint, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;POST /v1/decisions&lt;/code&gt;, with a request made of three parts: a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;model&lt;/code&gt; (currently only &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gpt-6-luna&lt;/code&gt;), an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;input&lt;/code&gt; (text, images, or both), and a list of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;questions&lt;/code&gt;. Each question has a type: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;predicate&lt;/code&gt; (true or false, returns a probability), &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;choice&lt;/code&gt; (pick one of your values, returns the choice, a probability per option, and a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;confidence&lt;/code&gt;), or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;score&lt;/code&gt; (rate against ordered levels, returns a weighted average). Nothing is generated, so you pay only for input, at $0.10 per million tokens. There is no explanation in the response. If you have read the earlier Jev post, this is the same shape as Jev’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Noul&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Choice&lt;/code&gt;, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Score&lt;/code&gt; questions.&lt;/p&gt;

&lt;p&gt;Our answer judge is one &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;choice&lt;/code&gt; question named &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;verdict&lt;/code&gt; with the options &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;correct&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;incorrect&lt;/code&gt;, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;partial&lt;/code&gt;, in that order, with the rubric in the instructions. That is the same question we sent to Jev and to every open model, serialized the same way; the request hashes match the ones from the JevK5-4B run.&lt;/p&gt;

&lt;p&gt;The docs describe the service as a public beta. Responses reported &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gpt-6-luna&lt;/code&gt; and no pinned version, so a rerun next month may not return the same answers.&lt;/p&gt;

&lt;h2 id=&quot;method&quot;&gt;Method&lt;/h2&gt;

&lt;p&gt;The test set is Human100: 100 document-grounded recall cases, frozen, with 67 answers a person accepted and 33 a person rejected. As before, each case was sent in two setups:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;J0: the question, the known fact, the candidate answer, and the named entities, plus the rubric.&lt;/li&gt;
  &lt;li&gt;E1: the same, plus the original source text.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each setup was run twice, so each cell below is 200 judgments over the same 100 cases. The second pass checks that the service is stable; it does not add independent cases. The score is accept/reject agreement with the human label: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;correct&lt;/code&gt; counts as accept, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;incorrect&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;partial&lt;/code&gt; both count as reject. A wrong answer marked correct is a false accept; a right answer marked wrong is a false reject.&lt;/p&gt;

&lt;p&gt;Requests went out serially from one HTTP client with a 60-second timeout and no retries. Two public control cases were run first to confirm the adapter worked, then the 100 private cases. The plan was written down before any private call, and the rubric was not changed after seeing scores. Every competitor number is read from its saved, audited record; nothing else was rerun. An independent audit of the OpenAI run passed 54,672 checks with zero errors.&lt;/p&gt;

&lt;h2 id=&quot;results-agreement-with-human-labels&quot;&gt;Results: agreement with human labels&lt;/h2&gt;

&lt;p&gt;The graphic at the top of the post summarizes this table; there, FA is false accepts (a wrong answer marked correct), FR is false rejects (a right answer marked wrong), and the asterisk on Jev’s timing marks a historical API measurement.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Model / tested recipe&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;J0 agreement&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;E1 agreement&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;J0 false accept / reject&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;E1 false accept / reject&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;Median J0 / E1 response time&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Jev&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;90%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;88.5%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;14 / 6&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;18 / 5&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;438 / 461 ms (historical API)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;JevK5-4B v0.3, Q8 / Metal&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;89%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;88%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;16 / 6&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;18 / 6&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;162 / 998 ms&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Mapika Decider-4B v2, BF16 / MPS&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;86%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;86%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;22 / 6&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;22 / 6&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;160 / 1,170 ms&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Jeeves 9B, no reasoning / MLX&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;84%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;85%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;28 / 4&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;30 / 0&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;887 / 3,870 ms&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;OpenAI Decisions, gpt-6-luna&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;83%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;82%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;16 / 18&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;14 / 22&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;135 / 136 ms (API)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;JevK5-9B v0.3.3, Q8 / Metal&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;82%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;88%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;22 / 14&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;20 / 4&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;311 / 1,938 ms&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Jeff 0.8B, original / MLX&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;81%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;72%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;24 / 14&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;52 / 4&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;23 / 163 ms&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Clef-flash 9B, BF16 / MPS&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;80%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;79%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;34 / 6&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;36 / 6&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;392 / 2,493 ms&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Strands Decider 2B v19, BF16 / MPS&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;73%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;68%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;54 / 0&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;64 / 0&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;309 / 825 ms&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Error counts cover both passes, so the denominators are 66 human-rejected and 134 human-accepted judgments per column. Clef-flash and Strands were tested after the last post and are included for completeness. Accepting everything scores 67% on this set, so 83% is well above the floor but in the middle of the pack, between Jeeves and the 9B JevK5. The local response times are warm measurements on a Mac from separate sessions; the OpenAI figure includes the network. They compare practical setups, not matched hardware.&lt;/p&gt;

&lt;h2 id=&quot;results-which-way-it-errs&quot;&gt;Results: which way it errs&lt;/h2&gt;

&lt;p&gt;The error columns are more informative than the totals. Per pass:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Grader&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;J0 false accepts (of 33)&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;J0 false rejects (of 67)&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;E1 false accepts (of 33)&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;E1 false rejects (of 67)&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;OpenAI Decisions&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;8 (24.2%)&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;9 (13.4%)&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;7 (21.2%)&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;11 (16.4%)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;JevK5-4B&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;8&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;3&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;9&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;3&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;On J0 the two graders let the same number of bad answers through, eight of 33. The six-point gap in agreement is entirely false rejects: OpenAI marked nine good answers wrong where JevK5-4B marked three. Adding the source text moved OpenAI in the usual direction for false accepts (eight down to seven) but made it reject more good answers (nine up to eleven). So it is stricter than Jev or JevK5-4B, not more discerning: a stricter grader that was also catching more bad answers would be a reasonable trade, and this one is not.&lt;/p&gt;

&lt;p&gt;Per pass, J0 returned 66 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;correct&lt;/code&gt;, 6 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;partial&lt;/code&gt;, and 28 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;incorrect&lt;/code&gt;; E1 returned 63, 9, and 28. It does use the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;partial&lt;/code&gt; label, which JevK5-4B never did on this set. Both passes returned identical verdicts on every case, with zero refusals and zero API errors across 400 calls.&lt;/p&gt;

&lt;p&gt;The responses include a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;confidence&lt;/code&gt; field and per-option probabilities. We stored and checked them but did not fit or test a cutoff for sending uncertain cases to Claude, so nothing here says whether that score is usable the way Jev’s turned out to be.&lt;/p&gt;

&lt;h2 id=&quot;is-the-six-point-gap-real&quot;&gt;Is the six-point gap real?&lt;/h2&gt;

&lt;p&gt;Paired differences, OpenAI minus the comparator, in percentage points, with 95% intervals from resampling the 100 cases (both passes of a case stay together):&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Comparator&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;J0 difference&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;E1 difference&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Jev&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;−7 [−13, −2]&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;−6.5 [−13.5, 0.5]&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;JevK5-4B&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;−6 [−14, 2]&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;−6 [−15, 3]&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Decider-4B v2&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;−3 [−11, 5]&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;−4 [−12, 4]&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Jeeves 9B&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;−1 [−10, 8]&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;−3 [−12, 6]&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Jeff 0.8B&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;2 [−6, 10]&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;10 [0, 21]&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Strands Decider 2B&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;10 [−1, 21]&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;14 [3, 26]&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;The gap to the Jev run on J0 is the one comparison whose interval excludes zero. Against JevK5-4B, the six-point gaps have intervals that cross zero: on the first pass there were six cases only OpenAI got right and twelve only JevK5-4B got right on J0 (seven versus thirteen on E1), which on 100 cases is suggestive, not conclusive. What can be said with more confidence is the direction of the errors, since the false-reject counts are not close. These are unadjusted comparisons on one reused development set; they do not rank the services in general.&lt;/p&gt;

&lt;h2 id=&quot;results-response-time-and-cost&quot;&gt;Results: response time and cost&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;OpenAI HTTP response time&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;J0&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;E1&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Median&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;135 ms&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;136 ms&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Mean&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;153 ms&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;152 ms&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;P95&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;242 ms&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;232 ms&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Timing covers the HTTP round trip and reading the full response, measured serially from one client. The E1 median, with roughly 2,300 to 4,100 input tokens per request, is about one-seventh of JevK5-4B’s saved local E1 median on a Mac, and the response time barely moved between the short and long inputs. That is the strongest result in this test. It is a workload timing, not a throughput benchmark; we did not test concurrency.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Usage and cost at list price&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;J0 (200 decisions)&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;E1 (200 decisions)&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Input tokens&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;81,386&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;549,316&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Input tokens per request&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;338–505&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;2,257–4,143&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Estimated cost&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;$0.0081&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;$0.0549&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Estimated cost per 1,000 decisions&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;$0.041&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;$0.275&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;The 400 private decisions used 630,702 input tokens, for about $0.063 at the published $0.10 per million input tokens. There are no output, cache, or reasoning token charges; the figure is computed from reported usage, not an invoice, and regional and long-context premiums can apply. For scale, the eval from &lt;a href=&quot;/cutting-answer-grading-cost-with-a-first-pass-grader&quot;&gt;the cost post&lt;/a&gt; graded about 2,350 answers with Opus for about $43.60; at the E1 rate above, 2,350 decisions would be about $0.65. Jev’s list price is $0.042 per million input tokens, so OpenAI’s per-token rate is about 2.4 times higher, though the two services count tokens differently and we did not measure Jev’s usage on this set.&lt;/p&gt;

&lt;h2 id=&quot;caveats&quot;&gt;Caveats&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;Human100 is one reused development set of 100 cases, all straightforward fact-recall questions. The numbers describe our workload.&lt;/li&gt;
  &lt;li&gt;The repeated passes check stability; they do not turn 100 cases into 200 independent ones.&lt;/li&gt;
  &lt;li&gt;The service is in public beta with one model alias and no pinned version. The results apply to what &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gpt-6-luna&lt;/code&gt; returned on 2026-10-06.&lt;/li&gt;
  &lt;li&gt;The six-point gap to JevK5-4B has a 95% interval that crosses zero. The difference in false-reject counts (18 versus 6 on J0) is the more solid finding.&lt;/li&gt;
  &lt;li&gt;Only the OpenAI run is new. Every other row is a saved result, and the response times were measured on different hardware, in different sessions, with and without a network in the path.&lt;/li&gt;
  &lt;li&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;confidence&lt;/code&gt; field was recorded but not tested. We cannot say whether a two-stage setup with Decisions in front of Claude would work.&lt;/li&gt;
  &lt;li&gt;Cost is estimated from reported token usage at the base rate, not billed.&lt;/li&gt;
  &lt;li&gt;Nothing in our production grading configuration has changed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;summary&quot;&gt;Summary&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;On Human100, OpenAI Decisions with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gpt-6-luna&lt;/code&gt; agreed with human accept/reject labels 83% of the time on J0 and 82% on E1, against 90% / 88.5% for the Jev run and 89% / 88% for JevK5-4B.&lt;/li&gt;
  &lt;li&gt;Per pass it marked 8 of 33 bad answers correct on J0, the same as JevK5-4B, but marked 9 of 67 good answers wrong where JevK5-4B marked 3. With source text that became 7 and 11.&lt;/li&gt;
  &lt;li&gt;Both passes returned identical verdicts on all 100 cases; 400 calls, zero errors, zero refusals.&lt;/li&gt;
  &lt;li&gt;Median response time was 135 ms on J0 and 136 ms on E1 including the network round trip, with a P95 under 250 ms. The long-input median was about one-seventh of JevK5-4B’s local figure.&lt;/li&gt;
  &lt;li&gt;All 400 judgments used 630,702 input tokens, about $0.063 at $0.10 per million, or roughly $0.041 per 1,000 short decisions and $0.275 per 1,000 with source text.&lt;/li&gt;
  &lt;li&gt;Its confidence score was stored but not tested, so no first-pass-filter result is available yet.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For this grading task we are staying with Jev hosted and JevK5-4B locally. The Decisions API is the fastest and one of the cheapest options we have measured, and if a later version closes the false-reject gap it would be worth a second look, with the confidence score tested at the same time.&lt;/p&gt;
</description>
        <pubDate>Tue, 06 Oct 2026 00:00:00 -0400</pubDate>
        <link>https://jshahbazi.github.io/testing-openai-decisions-api-as-an-answer-grader</link>
        <guid isPermaLink="true">https://jshahbazi.github.io/testing-openai-decisions-api-as-an-answer-grader</guid>
      </item>
    
      <item>
        <title>Testing JevK5-4B as a Free, Local Alternative to Jev for Answer Grading</title>
        <description>&lt;p&gt;Setup: Evaluating a trained model means having a second model grade each of its answers. The grader receives a question, the known fact, the candidate answer, and a rubric, and has to tell a correct answer from one that changes a number, confuses two entities, or leaves out something the question asked for. For few days, we have been using TypeSafe’s Jev for part of that job. Jev is a hosted service, and several open models now copy its interface. We wanted to know whether any of them could do our grading task on a machine we own.&lt;/p&gt;

&lt;!--more--&gt;

&lt;h2 id=&quot;short-version&quot;&gt;Short version&lt;/h2&gt;

&lt;p&gt;We tested Jeff, Jeeves, Kev, Decider, and two sizes of JevK5 on the same 100 hand-graded cases we used to evaluate Jev. JevK5-4B agreed with the human accept/reject labels 89% of the time, against 90% for our Jev run, with a median local response time of about 162 ms on a Mac. None of the other open models came as close. Replaying the same cases through a packaged local server reproduced every verdict. We have picked JevK5-4B as our preferred open model for further validation and local experiments. Our production grading configuration has not changed.&lt;/p&gt;

&lt;h2 id=&quot;background&quot;&gt;Background&lt;/h2&gt;

&lt;p&gt;&lt;a href=&quot;https://docs.typesafe.ai/introduction&quot;&gt;Jev&lt;/a&gt; does not write prose. You send it the input and a list of questions with fixed answer types, and it returns a decision and a probability for each option. Our answer judge is a single three-way choice: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;correct&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;incorrect&lt;/code&gt;, or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;partial&lt;/code&gt;. Software can consume that directly, with no verdict to extract from an explanation. I wrote up &lt;a href=&quot;/evaluating-jev-as-an-eval-grader&quot;&gt;how Jev compares to Claude as a grader&lt;/a&gt; earlier; this post is about the open models that imitate it.&lt;/p&gt;

&lt;p&gt;The candidates were:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/firelex/jeff&quot;&gt;Jeff&lt;/a&gt;, 0.8B parameters.&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/PostHog/jeeves&quot;&gt;Jeeves&lt;/a&gt;, 9B, tested with and without its reasoning mode.&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/jaredpalmer/kev&quot;&gt;Kev&lt;/a&gt;, 4B, from an earlier study.&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://huggingface.co/Mapika/decider-4b&quot;&gt;Decider&lt;/a&gt;, 4B, version 2.&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/allebee/jevk5&quot;&gt;JevK5&lt;/a&gt;, 4B (v0.3) and 9B (v0.3.3).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each was run locally in the serving recipe we would actually use, which means they differ in numeric precision, input formatting, and runtime. Where that matters it is noted below.&lt;/p&gt;

&lt;h2 id=&quot;method&quot;&gt;Method&lt;/h2&gt;

&lt;p&gt;The test set is Human100: 100 document-grounded recall cases, frozen and previously inspected, with 67 answers a person accepted and 33 a person rejected. Every model saw the same questions and the same fixed answers in two setups:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;J0: the fact, question, answer, named entities, and rubric.&lt;/li&gt;
  &lt;li&gt;E1: the same, plus the original source text.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The score is accept/reject agreement with the human label. A &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;correct&lt;/code&gt; verdict counts as accept; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;incorrect&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;partial&lt;/code&gt; both count as reject. Each completed local study sent every request twice. The models are deterministic, so the second pass checks stability; it does not turn 100 cases into 200 independent examples.&lt;/p&gt;

&lt;p&gt;All response times are warm measurements on an Apple M5 Max with 128 GB of RAM, taken in separate sessions rather than a controlled throughput benchmark. The Kev timings are server-reported model time only; the other local rows include tokenization and reading the result back. The Jev run has no matched local measurement.&lt;/p&gt;

&lt;h2 id=&quot;results-agreement-with-human-labels&quot;&gt;Results: agreement with human labels&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Model / tested recipe&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;J0 human agreement&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;E1 human agreement&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;Median J0 / E1 response time&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Jev&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;90.0%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;88.5%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;No matched local measurement&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Jeff 0.8B, original release&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;81.0%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;72.0%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;23 / 163 ms&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Jeeves 9B, without reasoning&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;84.0%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;85.0%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;887 / 3,870 ms&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Kev-4B, earlier study&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;82.0%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;74.0%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;212 / 796 ms*&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Decider-4B v2, BF16 / MPS&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;86.0%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;86.0%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;160 / 1,170 ms&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;JevK5-9B v0.3.3, Q8 / Metal&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;82.0%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;88.0%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;311 / 1,938 ms&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;JevK5-4B v0.3, Q8 / Metal&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;89.0%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;88.0%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;162 / 998 ms&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;* Server-reported model time, excluding tokenization and readout.&lt;/p&gt;

&lt;p&gt;JevK5-4B is one point behind the Jev run on J0 and half a point behind on E1, and it is the fastest of the models that scored above 85%. “Q8” means the weights were shrunk to 8-bit numbers; “Metal” and “MPS” mean the Mac’s GPU was used. The earlier Kev study used the same Human100 labels but a broader request that also asked secondary score questions, as did the Jev run.&lt;/p&gt;

&lt;h2 id=&quot;results-agreement-with-a-saved-opus-run&quot;&gt;Results: agreement with a saved Opus run&lt;/h2&gt;

&lt;p&gt;We also ran the models on Recall224, a second set of 224 fixed answers. That set has saved Claude Opus judgments rather than human labels, so these numbers say how often each model agreed with Opus, not how often it was right.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Model&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;Accept/reject agreement with Opus&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;JevK5-4B&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;91.96%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Decider v2&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;91.07%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;JevK5-9B&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;88.39%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Jeeves&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;85.71%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Jeff&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;85.27%&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;The ordering matches Human100 at the top, with JevK5-4B and Decider v2 close together and the rest behind.&lt;/p&gt;

&lt;h2 id=&quot;what-we-found-with-each-model&quot;&gt;What we found with each model&lt;/h2&gt;

&lt;h3 id=&quot;jeff&quot;&gt;Jeff&lt;/h3&gt;

&lt;p&gt;Jeff was the most tempting option on speed. A 0.8B model returning a short decision in roughly 23 ms would be useful if the decisions were good enough. On our task the original release reached 81% J0 agreement, and adding the source text dropped it to 72%.&lt;/p&gt;

&lt;p&gt;We tried to close that gap by training it further on public decision data and fictional examples across several domains. Two initial attempts scored 78% on Human100 J0. A broader recipe that retrained the final block reached 80%. An adapter across the whole backbone fell to 47%, despite scoring 100% on the generated development set it was trained against. Along the way we found and corrected a difference in how inputs were formatted in an early synthetic evaluation; the corrected results still showed more wrong answers being marked correct. These experiments rule out the recipes we tried, not what Jeff can learn. They did make us unwilling to pick it on speed alone.&lt;/p&gt;

&lt;h3 id=&quot;jeeves&quot;&gt;Jeeves&lt;/h3&gt;

&lt;p&gt;Jeeves without reasoning was our strongest completed local contender before the later comparison. It improved on Jeff and did well on our synthetic tests. On Human100 it reached 84% J0 agreement and accepted 14 of the 33 human-rejected answers per pass. JevK5-4B accepted eight. In the recipes we measured, the 4B was also about 5.5 times faster on J0.&lt;/p&gt;

&lt;p&gt;We tried Jeeves with reasoning turned on. We stopped the run after 185 judgments; the median response time was 44.6 seconds on our local BF16/MLX setup. On the cases it completed, reasoning improved J0 by one correct decision and left E1 unchanged. A partial run cannot establish the full model’s accuracy or predict how it would perform on CUDA hardware. It gave us little reason to keep paying that local time cost.&lt;/p&gt;

&lt;h3 id=&quot;kev-and-decider&quot;&gt;Kev and Decider&lt;/h3&gt;

&lt;p&gt;Both are credible alternatives with different strengths. Our older Kev-4B run reached 82% J0 and 74% E1. Decider v2 was much closer, at 86% in both setups, with a short-request response time essentially equal to JevK5-4B. Decider also passed a confidence screen on a second set where JevK5-4B failed (see “Confidence” below), so it deserves attention if the goal is deciding which cases a local model can handle alone.&lt;/p&gt;

&lt;p&gt;We tested Decider v2 specifically. Its current model card says that v2.1’s confidence scores track its accuracy less well on hard items. Neither v2.1 nor Kev-9B has been tested here; their absence from the table is work not yet done, not a negative result. Older studies also recorded djev at 89% J0 / 85% E1 and Laya at 63% as shipped, rising to 81% after training on Brekko labels. Those runs lack the same complete recipe and timing record, so they are kept separate from the table above.&lt;/p&gt;

&lt;h3 id=&quot;jevk5-9b&quot;&gt;JevK5-9B&lt;/h3&gt;

&lt;p&gt;The larger JevK5 was a useful check. The tested 9B v0.3.3 release reached 82% J0 and 88% E1 while taking roughly twice as long as the 4B. More parameters did not improve the aggregate result on this workload.&lt;/p&gt;

&lt;h2 id=&quot;replaying-through-the-packaged-server&quot;&gt;Replaying through the packaged server&lt;/h2&gt;

&lt;p&gt;The research harness is one thing; the serving path we would actually use is another. We replayed Human100 through our installed JevK5 MCP server (MCP is a standard protocol for exposing a tool to other software), making all 200 J0 and E1 decisions through that interface. Every verdict matched the previous 4B run. Probability differences were at floating-point rounding scale, with no inference errors and no cache hits. Median response times through the server were 165 ms for J0 and 901 ms for E1. An independent audit of the replay passed 1,237 checks. This shows the packaged service preserves the tested behaviour; it adds no new accuracy evidence.&lt;/p&gt;

&lt;h2 id=&quot;what-free-means-here&quot;&gt;What “free” means here&lt;/h2&gt;

&lt;p&gt;JevK5-4B’s &lt;a href=&quot;https://github.com/allebee/jevk5&quot;&gt;code&lt;/a&gt; and &lt;a href=&quot;https://huggingface.co/alibiserikbay/JevK5-GGUF&quot;&gt;released weights&lt;/a&gt; are Apache-2.0. The Q8 file we tested is about 4.48 GB and runs through llama.cpp on Apple Silicon, other supported GPUs, or a CPU. Our measurements used the Mac’s GPU; we have not benchmarked a CPU deployment.&lt;/p&gt;

&lt;p&gt;“Free” means downloadable weights and code with no per-decision fee to a model vendor. Hardware, electricity, operations, and any hosted fallback still cost money, and we have not measured fleet economics. For a model kept resident on hardware you already own, being able to run locally and control the serving environment is worth something before any cost saving is assigned to it.&lt;/p&gt;

&lt;h2 id=&quot;confidence&quot;&gt;Confidence&lt;/h2&gt;

&lt;p&gt;In the earlier Jev post, the useful part turned out to be the confidence score: using it to decide which cases a cheap grader could settle alone, and sending the rest to Opus. We repeated that check for JevK5-4B. In a development replay on Human100, a two-stage setup with JevK5-4B in front of Opus settled 57% of decisions locally and reached 93.5% agreement with the human labels, matching the Jev two-stage setup at similar coverage.&lt;/p&gt;

&lt;p&gt;The same cutoff did not hold on Recall224. It failed both our disagreement screen and our screen for wrong answers being marked correct, and a stricter cutoff settled too few cases to be useful. So the bar we wrote down for replacing the production grader is still unmet, and the confidence score needs its own validation before it can be used to route cases.&lt;/p&gt;

&lt;h2 id=&quot;what-still-needs-work&quot;&gt;What still needs work&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;JevK5-4B still marks eight of Human100’s 33 human-rejected answers as correct on J0.&lt;/li&gt;
  &lt;li&gt;Its five-point lead over Jeeves has a paired 95% interval of −1 to +11 points. On 100 cases that is not enough to establish broad superiority.&lt;/li&gt;
  &lt;li&gt;It returned no &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;partial&lt;/code&gt; verdicts on Human100. Accept/reject agreement alone cannot certify all three grading labels.&lt;/li&gt;
  &lt;li&gt;Separate fictional controls exposed a material omission it accepted, and its probabilities changed when equivalent options were reordered.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;caveats&quot;&gt;Caveats&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;Human100 is one reused development set of 100 cases. The numbers describe our workload, not arbitrary customer domains.&lt;/li&gt;
  &lt;li&gt;The repeated passes check that the models are deterministic; they do not add independent cases.&lt;/li&gt;
  &lt;li&gt;Recall224 agreement is against saved Opus judgments, not human labels.&lt;/li&gt;
  &lt;li&gt;The models were run in different recipes (precision, formatting, runtime) on one Mac in separate sessions. The response times compare practical setups, not a controlled benchmark.&lt;/li&gt;
  &lt;li&gt;Decider v2.1 and Kev-9B were not tested. The Jeeves reasoning run was partial.&lt;/li&gt;
  &lt;li&gt;The results apply to the specific released versions named in the table.&lt;/li&gt;
  &lt;li&gt;The MCP replay confirms the serving path reproduces the harness; it is not additional accuracy evidence.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;summary&quot;&gt;Summary&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;On Human100, JevK5-4B v0.3 Q8 agreed with the human labels 89.0% of the time on J0 and 88.0% on E1, against 90.0% and 88.5% for the Jev run.&lt;/li&gt;
  &lt;li&gt;Median local response time was 162 ms on J0 and 998 ms on E1; the packaged MCP server measured 165 ms and 901 ms and reproduced all 200 verdicts.&lt;/li&gt;
  &lt;li&gt;The next-best open models were Decider v2 at 86.0% / 86.0% and Jeeves at 84.0% / 85.0%. Jeeves accepted 14 of 33 rejected answers per pass; JevK5-4B accepted eight.&lt;/li&gt;
  &lt;li&gt;Jeff was fastest at 23 ms but reached only 81% J0 agreement, and our training attempts peaked at 80%.&lt;/li&gt;
  &lt;li&gt;The 9B JevK5 scored 82% / 88% and took about twice as long as the 4B.&lt;/li&gt;
  &lt;li&gt;On Recall224, JevK5-4B agreed with Opus 91.96% of the time, ahead of Decider v2 at 91.07%.&lt;/li&gt;
  &lt;li&gt;A two-stage setup settled 57% of Human100 locally at 93.5% agreement, but the same cutoff failed our screens on Recall224, so the production replacement bar is unmet.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For anyone looking for a free, local Jev-style answer judge, JevK5-4B v0.3 Q8 is the model we would try first. Our next step is fresh human grading focused on omissions, factual near misses, partial answers, and the confidence cutoff.&lt;/p&gt;
</description>
        <pubDate>Thu, 01 Oct 2026 00:00:00 -0400</pubDate>
        <link>https://jshahbazi.github.io/testing-jevk5-4b-as-a-free-local-jev-alternative</link>
        <guid isPermaLink="true">https://jshahbazi.github.io/testing-jevk5-4b-as-a-free-local-jev-alternative</guid>
      </item>
    
      <item>
        <title>Cutting LLM Answer-Grading Cost by 75% With a Cheap First-Pass Grader</title>
        <description>&lt;p&gt;&lt;img src=&quot;/images/two-stage-grading-flow.png&quot; alt=&quot;Two-stage grading flow: 2,352 answers per eval go to Jev first; verdicts with confidence at or above 0.95 stand and agree with Opus 99.1–99.8% of the time, the rest go to Claude Opus 5. Cost per eval falls from about $43.60 to about $11, a 75% saving.&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Setup: we train a small open model on a customer’s own documents. Every training run is evaluated three times: after stage 1, after stage 2, and once more at the end. Each eval asks the trained model 2,350 test questions and has a large commercial model, Claude Opus 5 on Vertex AI, grade each answer against the known fact. For questions answerable from the documents, Opus also sees a window of the source text, and that window was worth about four points of accuracy against human labels in an earlier test. Three evals per run, times a few thousand graded answers each, adds up.&lt;/p&gt;

&lt;!--more--&gt;

&lt;p&gt;This post is about how we cut the grading cost by about 75%, from roughly $43.60 to about $11 per eval, while the verdicts we keep still agree with Opus about 99 times in 100. Two things up front. This is the grading cost only; GPU training and the other model calls in the pipeline are unchanged. And the dollar figures are estimates from token prices, not billed amounts, because we do not yet record billed grader usage.&lt;/p&gt;

&lt;h2 id=&quot;short-version&quot;&gt;Short version&lt;/h2&gt;

&lt;p&gt;We put TypeSafe’s Jev in front of Opus as a first pass. Jev grades every answer and returns a probability for its choice. If that probability clears a cutoff, its verdict stands; otherwise the answer goes to Opus exactly as before. With a cutoff of 0.95, Jev handles 60–62% of source-document questions and 91–92% of the rest. On the rows it handles, it agrees with Opus 99.1–99.8% of the time, which is about as often as Opus agrees with itself. The first version of this saved 35%; the saving came from coverage, not from Jev’s unit price.&lt;/p&gt;

&lt;h2 id=&quot;where-the-money-went&quot;&gt;Where the money went&lt;/h2&gt;

&lt;p&gt;The eval we measured had 2,352 questions in six categories:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Category&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;Count&lt;/th&gt;
      &lt;th&gt;What it tests&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Recall&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;854&lt;/td&gt;
      &lt;td&gt;Facts answerable from the documents&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Trick variants, category A&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;928&lt;/td&gt;
      &lt;td&gt;Questions designed to catch the model out&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Trick variants, category B&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;344&lt;/td&gt;
      &lt;td&gt;Same, a second category&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Kept-out&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;148&lt;/td&gt;
      &lt;td&gt;Answers deliberately left out of training; the right response is to decline&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Nearby out-of-scope&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;50&lt;/td&gt;
      &lt;td&gt;Questions close to, but outside, what the documents cover&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Regression&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;28&lt;/td&gt;
      &lt;td&gt;General behaviour has not broken&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;I will call the last five categories “non-recall” from here on.&lt;/p&gt;

&lt;p&gt;An Opus recall verdict costs about $0.0265, because it also reads a source window of roughly 3,300 tokens. A non-recall verdict costs about $0.014, and about 72% of that is output: Opus writes back a structured verdict with scores and a short explanation, and output tokens are the expensive kind.&lt;/p&gt;

&lt;h2 id=&quot;what-jev-is-and-why-it-is-not-a-replacement&quot;&gt;What Jev is, and why it is not a replacement&lt;/h2&gt;

&lt;p&gt;Jev is TypeSafe’s decision model. It writes no prose. You give it the allowed verdicts and it returns a choice plus a probability for each. Input costs $0.042 per million tokens and output is free, which works out to about $0.000033 per verdict, several hundred times cheaper than Opus.&lt;/p&gt;

&lt;p&gt;I &lt;a href=&quot;/evaluating-jev-as-an-eval-grader&quot;&gt;tested Jev against Opus earlier&lt;/a&gt; on a 100-case set graded by hand. Without the source document, the two tied at 89.0% agreement with the human labels. But Jev cannot use the source document; give it the window and it gets worse. Swapping graders outright would have handed back the four points the source window bought us. So Jev is a first pass, not a replacement.&lt;/p&gt;

&lt;h2 id=&quot;what-we-built&quot;&gt;What we built&lt;/h2&gt;

&lt;p&gt;The two-stage setup is a per-run option, off by default. Jev grades first. If the probability of its top choice clears the cutoff, its verdict stands. Otherwise the answer goes to Opus with the same request it would have received anyway.&lt;/p&gt;

&lt;p&gt;Failure handling is strict. Jev timeouts, rate limits and server errors send the answer to Opus. Configuration errors, such as a bad key or an unknown model name, fail the eval before any Opus money is spent. A failed Jev call is never turned into a verdict.&lt;/p&gt;

&lt;p&gt;We treat a different grader as a different measuring instrument, because it is one. Each grading mode has its own identity string naming both models and the cutoff. The eval job checks at startup that it built the same identity the orchestrator wrote, and refuses to start, before any GPU or grading spend, if they differ. Stability comparisons between runs never mix runs scored by different graders.&lt;/p&gt;

&lt;p&gt;Finally, we copied the grading instructions byte for byte from the research harness that measured them rather than retyping them. All 2,352 request bodies are byte-identical to what was tested.&lt;/p&gt;

&lt;p&gt;Version 1 covered recall questions only, with the cutoff at 0.90.&lt;/p&gt;

&lt;h2 id=&quot;method&quot;&gt;Method&lt;/h2&gt;

&lt;p&gt;Every comparison is a paired eval: the same trained model and the same questions, with only the grader changing, joined question by question.&lt;/p&gt;

&lt;p&gt;Two things about the measurement had to be settled first.&lt;/p&gt;

&lt;p&gt;Generation at temperature 0 was not deterministic. Only about 80% of answers were byte-identical across two evals of the same model. If you compare verdicts on answers that differ, generation noise looks like grader disagreement. So all agreement figures below are on questions where both evals produced the identical answer.&lt;/p&gt;

&lt;p&gt;Opus disagrees with itself. On the same inputs, Opus versus Opus agreed 98.7% of the time on hard recall rows and 99.6% on non-recall. Any gap between Jev and Opus has to be read against that floor.&lt;/p&gt;

&lt;p&gt;On the disagreements we ran a standard paired significance test (exact McNemar) and split them two ways. “Lenient” means Jev said the answer was right and Opus said it was wrong. “Strict” is the reverse. Lenient errors are worse, because they flatter the model being evaluated.&lt;/p&gt;

&lt;p&gt;For the check on a second trained model, we wrote down the pass bar before looking at the results:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Non-recall agreement with Opus of at least 99.0%.&lt;/li&gt;
  &lt;li&gt;Recall agreement of at least 98.7%, matching the Opus self-agreement floor.&lt;/li&gt;
  &lt;li&gt;No category significantly lenient.&lt;/li&gt;
  &lt;li&gt;At most one lenient error on kept-out questions.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;results&quot;&gt;Results&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Comparison&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;Cutoff&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;Handled by Jev&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;Agreement with Opus&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;Lenient errors&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Recall, first model&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;0.90&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;67.7%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;99.2%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;3&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Non-recall, first model, re-graded offline&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;0.90&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;94%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;99.6%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;2&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Non-recall, first model, re-graded offline&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;0.95&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;92%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;99.7%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;0&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Non-recall, second model&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;0.95&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;91%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;99.7%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;0&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Recall, second model&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;0.90&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;67.6%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;98.4%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;6&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Recall, both models, re-cut&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;0.95&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;60–62%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;99.1% and 99.8%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;4 in total&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Agreement is measured on Jev-handled rows with identical answers.&lt;/p&gt;

&lt;h3 id=&quot;recall-only-first-model&quot;&gt;Recall only, first model&lt;/h3&gt;

&lt;p&gt;Jev handled 67.7% of recall verdicts. On those it agreed with Opus 99.2% of the time, with 3 lenient and 1 strict disagreement. The headline recall score moved −0.2 points (p=1.0, too close to tell apart). The saving was only about 35%. The limit was coverage, not price: 74% of the remaining cost was non-recall questions still sent to Opus every time.&lt;/p&gt;

&lt;h3 id=&quot;non-recall-offline&quot;&gt;Non-recall, offline&lt;/h3&gt;

&lt;p&gt;Before touching the non-recall categories in a live eval, we had Jev re-grade the exact answers Opus had already graded. Without a cutoff, it was not safe. On kept-out questions Jev accepted 7 of 148 answers as proper declines that Opus said were not (p=0.016). That is the worst kind of error on the product’s weakest skill, knowing when not to answer. It was also stricter than Opus on one of the trick-question categories (p=0.011).&lt;/p&gt;

&lt;p&gt;With the cutoff at 0.90, Jev handled 94% with 99.6% agreement and 2 lenient errors. At 0.95 it handled 92% with 99.7% agreement and no lenient errors. We chose 0.95 for non-recall, knowing it was read off the same data it was tested on.&lt;/p&gt;

&lt;h3 id=&quot;second-model&quot;&gt;Second model&lt;/h3&gt;

&lt;p&gt;Non-recall passed. Jev handled 91%, with 99.7% agreement on 1,071 comparable rows, 0 lenient and 3 strict. On kept-out questions, 0 lenient of 95.&lt;/p&gt;

&lt;p&gt;Recall failed. At the 0.90 cutoff, agreement was 98.4% on 494 rows, with 6 lenient and 2 strict, under the 98.7% bar. The result was not significantly different from the first model, and the headline recall score moved only +0.1 points. But across both models Jev leaned lenient on recall, 9 lenient against 3 strict (p=0.15). That is the direction we care about.&lt;/p&gt;

&lt;p&gt;We tightened the system, not the bar. We raised the recall cutoff to 0.95. Re-applying that cutoff to the recorded probabilities gives 99.1% and 99.8% agreement on the two models, with 4 lenient errors against 1 across both. A stricter cutoff can only send more answers to Opus, so it cannot do worse than the looser one. The price is a saving of 75% instead of 79%.&lt;/p&gt;

&lt;h2 id=&quot;what-it-costs-now&quot;&gt;What it costs now&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Setup&lt;/th&gt;
      &lt;th&gt;Grading cost per 2,352-question eval&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;All Opus&lt;/td&gt;
      &lt;td&gt;about $43.60&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Two-stage, recall cutoff 0.90&lt;/td&gt;
      &lt;td&gt;about $9.34 (79% saving)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Two-stage, recall cutoff 0.95 (what we ship)&lt;/td&gt;
      &lt;td&gt;about $11 (75% saving)&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Both two-stage rows use a 0.95 cutoff for non-recall. Per training run, with three evals, that is roughly $130 down to $33.&lt;/p&gt;

&lt;h2 id=&quot;what-the-results-mean&quot;&gt;What the results mean&lt;/h2&gt;

&lt;p&gt;Coverage sets the saving, not unit price. Jev is several hundred times cheaper per verdict, and version 1 saved 35%. Every question that still goes to Opus costs what it always did. The gain came from safely extending the cheap path to more categories.&lt;/p&gt;

&lt;p&gt;The confidence cutoff is what made non-recall safe. Without it, Jev accepted 7 kept-out answers it should not have. At 0.95 it accepted none, on both models. The probability Jev returns finds the rows where it is unsure, without being told what those rows look like.&lt;/p&gt;

&lt;p&gt;Lenient and strict disagreements are not interchangeable. Two graders can disagree on 1% of rows in a harmless way or a harmful one. Only the split told us Jev leaned lenient on recall; the headline recall scores moved by −0.2 and +0.1 points and would not have.&lt;/p&gt;

&lt;p&gt;The remaining points are method notes, stated as rules we would reuse:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Compare identical outputs only. Temperature 0 gave us byte-identical answers about 80% of the time; the other 20% is generation noise that would have been counted as grader disagreement.&lt;/li&gt;
  &lt;li&gt;Read disagreement against the grader’s own noise. Opus agrees with itself 98.7% to 99.6% of the time; a 99.2% Jev-to-Opus figure sits inside that.&lt;/li&gt;
  &lt;li&gt;Fix the pass bar before you look. When you miss it, tighten the system, not the bar. We gave up four points of saving and kept the meaning of the metric.&lt;/li&gt;
  &lt;li&gt;Label a new grader as a new instrument and never compare across the line. A cheaper grader that quietly inherited the old grader’s baselines would pass checks against numbers it never produced.&lt;/li&gt;
  &lt;li&gt;Ship the exact bytes you measured. Retyped rubrics are a new experiment nobody ran. Generating the requests from the harness and checking byte equality cost little and removed a class of doubt.&lt;/li&gt;
  &lt;li&gt;Fail at startup, before spending money. An identity mismatch stops the job before any GPU or grading spend; a bad key stops it before the first Opus call.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;caveats&quot;&gt;Caveats&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;The 0.95 cutoffs were chosen from the same data we evaluated them on. A third trained model would show whether they hold.&lt;/li&gt;
  &lt;li&gt;The out-of-scope (50) and regression (28) categories are too small to read on their own.&lt;/li&gt;
  &lt;li&gt;Agreement is measured against Opus, not against humans. A sealed, hand-graded set covering every category is still to do.&lt;/li&gt;
  &lt;li&gt;Billed grader cost is not recorded yet; the dollar figures are token-price estimates.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;summary&quot;&gt;Summary&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;Grading cost per eval went from about $43.60 to about $11, a 75% saving, on grading only.&lt;/li&gt;
  &lt;li&gt;Jev handles 60–62% of recall verdicts and 91–92% of non-recall verdicts at a 0.95 cutoff.&lt;/li&gt;
  &lt;li&gt;On the rows it handles, Jev agrees with Opus 99.1–99.8%, against an Opus self-agreement floor of 98.7–99.6%.&lt;/li&gt;
  &lt;li&gt;Without a cutoff, Jev accepted 7 of 148 kept-out answers Opus rejected; at 0.95 it accepted 0 on both models.&lt;/li&gt;
  &lt;li&gt;Recall at the 0.90 cutoff missed the 98.7% bar on the second model (98.4%, 6 lenient vs 2 strict), so we raised the cutoff rather than the bar and gave up four points of saving.&lt;/li&gt;
  &lt;li&gt;Version 1 saved 35% with the same per-verdict price; the rest came from extending coverage to non-recall categories.&lt;/li&gt;
&lt;/ul&gt;
</description>
        <pubDate>Wed, 23 Sep 2026 00:00:00 -0400</pubDate>
        <link>https://jshahbazi.github.io/cutting-answer-grading-cost-with-a-first-pass-grader</link>
        <guid isPermaLink="true">https://jshahbazi.github.io/cutting-answer-grading-cost-with-a-first-pass-grader</guid>
      </item>
    
      <item>
        <title>Evaluating Jev as a Replacement for an LLM Answer Grader</title>
        <description>&lt;p&gt;Setup: we train small language models on collections of documents, then check how well each one learned the material by asking it a few thousand questions and having a large commercial model (Claude) grade the answers against a rubric. The grading step costs about ~$100 per run in API spend. For scale, an hour on an 8-GPU machine costs $29.40, so grading is roughly 3.5 hours of GPU cost per run, spent deciding whether the GPU time was worth it.&lt;/p&gt;

&lt;!--more--&gt;

&lt;p&gt;TypeSafe recently released Jev, and I spent a few days testing it as a replacement for that grader. This post is the writeup. The short version: it matches Claude on grading accuracy when neither is shown the source document, falls behind when both are, and returns a confidence score that is reliable enough to make decisions on. Used as a first-pass filter in front of Claude rather than a replacement, it cuts the grading bill by about 78% at the cost of half a percentage point of accuracy.&lt;/p&gt;

&lt;h2 id=&quot;what-jev-is&quot;&gt;What Jev is&lt;/h2&gt;

&lt;p&gt;Jev does not generate text. It is a model that answers structured questions about an input. You send it a chunk of data and a list of questions with fixed answer types, and it returns answers in those types. The question types are &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Choice&lt;/code&gt; (pick one of N options; returns the option, a probability for each option, and an overall confidence score), &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Score&lt;/code&gt; (a number against a rubric), and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Noul&lt;/code&gt; (yes/no). Because nothing is generated, you only pay for input: $0.042 per million input tokens. Median response time on my calls was about 400ms.&lt;/p&gt;

&lt;p&gt;This matters because our grader is already, functionally, a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Choice&lt;/code&gt; question. We prompt Claude for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;{&quot;verdict&quot;: &quot;correct&quot; | &quot;incorrect&quot; | &quot;partial&quot;, &quot;confidence&quot;: 0.0-1.0, &quot;reasoning&quot;: &quot;...&quot;}&lt;/code&gt; and parse the result. Around that we have retry logic and a function that recovers a verdict from cut-off JSON. That function exists because one run had 472 of 6,657 verdicts get truncated at the output length limit and default to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;incorrect&lt;/code&gt;, which silently invalidated the run. None of that code is about grading. It is the cost of asking a text generator for a structured decision.&lt;/p&gt;

&lt;h2 id=&quot;method&quot;&gt;Method&lt;/h2&gt;

&lt;p&gt;I ported our four grading rubrics to Jev. Each rubric’s CORRECT/INCORRECT/PARTIAL criteria became the three options of a single &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Choice&lt;/code&gt; question. I then ran both graders against 100 cases that a person had already graded by hand, two passes each, for 200 judgments per grader. The metric throughout is how often the grader agrees with the human.&lt;/p&gt;

&lt;p&gt;Two conditions: with and without the source document. Our production grader gets a ~10,600-character window of the original document around the fact being tested. We added that the week before this test; it moved Claude from 89.0% to 93.0%, mostly by catching wrong answers it had previously marked correct.&lt;/p&gt;

&lt;h2 id=&quot;results-grading-accuracy&quot;&gt;Results: grading accuracy&lt;/h2&gt;

&lt;p&gt;Without the source document:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Grader&lt;/th&gt;
      &lt;th&gt;Source evidence&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;Human-label agreement&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Claude Opus 5&lt;/td&gt;
      &lt;td&gt;none&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;89.0%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Jev&lt;/td&gt;
      &lt;td&gt;none&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;89.0%&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Identical, and not just in total: the same 178 of 200 judgments are correct. The two graders agree with each other on 92 of 100 cases, are both wrong on 7, and each is uniquely right on 4. Across roughly 4,800 Jev calls total there were zero parse failures and zero truncated outputs.&lt;/p&gt;

&lt;p&gt;With the source document:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Grader&lt;/th&gt;
      &lt;th&gt;Source evidence&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;Human-label agreement&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Claude Opus 5&lt;/td&gt;
      &lt;td&gt;10,600-char window&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;93.0%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Jev&lt;/td&gt;
      &lt;td&gt;10,600-char window&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;88.5%&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Jev gets slightly worse when shown the source. TypeSafe’s docs describe this failure mode directly (“large state full of irrelevant detail — filter first; send only what the question needs”). The windows are three neighboring document chunks picked by position, so most of the text has nothing to do with the question.&lt;/p&gt;

&lt;h2 id=&quot;trimming-the-source-text-did-not-fix-it&quot;&gt;Trimming the source text did not fix it&lt;/h2&gt;

&lt;p&gt;If the problem is too much irrelevant text, the obvious fix is to shrink the window. I wrote four filters and cut the windows to 7.5–10.9% of their original length:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Keep the sentences that share the most words with the question. No model involved.&lt;/li&gt;
  &lt;li&gt;Ask Gemini Flash to extract the relevant sentences, two prompt variants.&lt;/li&gt;
  &lt;li&gt;Ask Jev a yes/no &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Noul&lt;/code&gt; question (“is this sentence relevant?”) about every sentence at once, in a single call.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Before running anything I wrote down 91.0% as the bar for “partially fixed.” The best filter came in at 90.5%, and all four were too close to each other to tell apart. So: no.&lt;/p&gt;

&lt;p&gt;Looking at why was more useful than the result. One case in the set had been flagged by our human reviewer as the key example: the model’s answer applied a rule from paragraph b(6) when the question asked about b(5). All four filters kept both paragraphs. The most aggressive one reduced 74 sentences to four (981 characters), and those four were, consecutively and word for word:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;(5) The appropriate qualification standard and any selective certification or
quality ranking factors which may be appropriate;
(6) The appropriate recruitment sources and techniques to use and whether to
use them singularly or in combination.
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Jev marked the answer correct with all four filters, both passes. Across the failed cases where the reviewer left a note, the filtered windows kept 87.5% of the specific terms the reviewer pointed to in the source, which is higher than the filters’ average. The relevant text was there.&lt;/p&gt;

&lt;p&gt;So this is not a problem of too much irrelevant text. Rejecting that answer requires noticing that the rule came from the paragraph next door, not the one the question asked about. That is a reasoning step Jev does not appear to take. It handles “does the source support this claim” reasonably well; it does not handle “this claim is supported by the wrong part of the source.”&lt;/p&gt;

&lt;h2 id=&quot;a-mistake-i-almost-made&quot;&gt;A mistake I almost made&lt;/h2&gt;

&lt;p&gt;The two graders disagreed on 290 judgments. To break the ties I sent those rows to Claude Opus 5 as a tiebreaker, without telling it which grader said what, using our own grading prompts. Then I repeated it with a model from a different vendor.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Tiebreaker&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;Sides with Jev&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;Sides with Claude grader&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Claude Opus 5&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;73&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;155&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Gemini 3.8 Flash&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;137&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;100&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;The result flips depending on which tiebreaker you ask. Both pass the same sanity check: on rows where the two graders already agreed, they side with the agreed verdict 95.8% and 91.7% of the time respectively. But our grader is Claude, and using Claude to judge Claude roughly doubled its apparent advantage. Counting only the rows where both tiebreakers agree gives 79 to 58 in Claude’s favor: a real edge, but less than half of what the first number suggested.&lt;/p&gt;

&lt;p&gt;If you are using an LLM to settle disagreements between graders, use one from a different vendor than any grader being tested, or use two and only count the rows where they agree.&lt;/p&gt;

&lt;h2 id=&quot;is-the-confidence-score-any-good&quot;&gt;Is the confidence score any good?&lt;/h2&gt;

&lt;p&gt;Every Jev response includes a confidence score. It is computed from the probabilities Jev assigns to each option, not written out as text by the model. I had been ignoring it because I was evaluating verdicts.&lt;/p&gt;

&lt;p&gt;Grouping judgments by the confidence the grader reported:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Reported confidence&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;Jev rows&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;Jev agreement&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;Claude rows&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;Claude agreement&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;0.2–0.3&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;59&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;37.3%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;—&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;—&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;0.5–0.6&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;91&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;58.2%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;11&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;54.5%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;0.6–0.7&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;113&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;64.6%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;25&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;12.0%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;0.7–0.8&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;131&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;74.8%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;149&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;25.5%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;0.9–1.0&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;1,587&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;97.9%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;1,833&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;97.1%&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Jev’s accuracy rises steadily with its reported confidence across the whole range: when it says it is unsure, it is usually wrong, and when it says it is sure, it is usually right. Claude’s self-reported &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;confidence&lt;/code&gt; field does not behave this way. It never reports a value below 0.5, it puts 79% of rows at 0.9 or above, and it is less accurate in the 0.6–0.7 bucket than in the 0.5–0.6 bucket. We use a threshold of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;min_judge_confidence = 0.70&lt;/code&gt; to decide which judgments a human should look at. On Claude’s self-report that threshold flags 1.6% of rows for review. On Jev’s it flags 18.9%, and those rows really are the disputed ones.&lt;/p&gt;

&lt;h2 id=&quot;jev-as-a-first-pass-filter&quot;&gt;Jev as a first-pass filter&lt;/h2&gt;

&lt;p&gt;Given a confidence score you can trust, the natural setup is two stages: accept Jev’s verdict where it is confident, and send the rest to Claude.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Configuration&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;Jev serves&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;Human-label&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;Cost/run&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Current: Claude + full window&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;—&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;93.0%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;$109&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Claude + question-conditioned window&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;—&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;92.5%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;$71&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Jev → Claude + full window&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;57.0%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;92.5%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;$47&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Jev + Claude, one shared window&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;66.5%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;92.5%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;$24&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;0.5 points is one judgment in 200. Cost drops by about 78%.&lt;/p&gt;

&lt;p&gt;This works because the confidence threshold picks out exactly the rows where Jev is not worse. On the two thirds of rows Jev is confident about, Jev and Claude both score 95.5%, and they mark the same six wrong answers as correct. On the third Jev flags as uncertain, it trails Claude by 7.5 points. Splitting the rows randomly at the same ratio would not work, since Jev on its own is 3–4 points behind overall.&lt;/p&gt;

&lt;p&gt;The second row is worth noting on its own because it requires no new vendor. Cutting the source window down to roughly 900 characters costs Claude nothing in accuracy (92.5% either way) and halves the input tokens, as long as the filter is based on the question rather than on the answer being graded. Since both the question and the document are fixed, the filtered window can be computed once per question set and cached.&lt;/p&gt;

&lt;h2 id=&quot;caveats&quot;&gt;Caveats&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;I picked the confidence threshold after the fact, on the same 100 cases I used to measure Jev’s confidence in the first place. That is circular. The overall shape (higher confidence, higher accuracy) is probably real; the specific threshold and the 92.5% figure are not validated.&lt;/li&gt;
  &lt;li&gt;The test set is straightforward fact-recall questions only. Jev’s grading agreement was weakest on trick questions designed to catch the model out, which is exactly where marking a wrong answer correct does the most damage.&lt;/li&gt;
  &lt;li&gt;Only 100 cases. The differences between the four filters, and between Jev and Claude when neither sees the source, are too small to be meaningful.&lt;/li&gt;
  &lt;li&gt;This saves money; it does not improve accuracy. If you could somehow pick the better grader for each case, you would score 94.0–94.5%. The two-stage setup captures none of that.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A proper test would fix the threshold on one set of cases and then score it on a fresh, human-graded set covering every question type.&lt;/p&gt;

&lt;h2 id=&quot;summary&quot;&gt;Summary&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;Jev matches Claude on grading accuracy without the source document (89.0% both) and falls behind with it (88.5% vs 93.0%).&lt;/li&gt;
  &lt;li&gt;The gap is not fixable by filtering the source text. It comes from a reasoning step (noticing a claim is backed by the wrong passage) that the model does not perform.&lt;/li&gt;
  &lt;li&gt;Settling grader disagreements with a model from the same vendor as one of the graders roughly doubles that grader’s apparent advantage.&lt;/li&gt;
  &lt;li&gt;Jev’s confidence score means what it says; our Claude grader’s self-reported confidence does not, and tells you almost nothing below 0.9.&lt;/li&gt;
  &lt;li&gt;Using Jev’s confidence to decide which cases go to Claude keeps accuracy within 0.5 points and cuts grading cost by ~78%, subject to the caveats above.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are evaluating a model like this, measure the confidence score, not just the verdict. In our case the verdict was a tie and the confidence was the result.&lt;/p&gt;
</description>
        <pubDate>Sun, 20 Sep 2026 00:00:00 -0400</pubDate>
        <link>https://jshahbazi.github.io/evaluating-jev-as-an-eval-grader</link>
        <guid isPermaLink="true">https://jshahbazi.github.io/evaluating-jev-as-an-eval-grader</guid>
      </item>
    
      <item>
        <title>The Before Column</title>
        <description>&lt;p&gt;I thought I’d figured out why our second training stage was making a model forget facts. Several cuts of the data agreed, the explanation made sense, and it pointed to a fairly straightforward fix. Then I looked more carefully at the column showing where each group started.&lt;/p&gt;

&lt;!--more--&gt;

&lt;p&gt;We train small models on document collections. The first stage teaches the facts. The second teaches things like refusing questions the documents can’t answer and pushing back on false premises. In this run, accuracy on fresh questions fell from about 58% to 43% after the second stage.&lt;/p&gt;

&lt;p&gt;Fifteen points is a lot to lose. We’d also just tried changing the composition of the training data, with no measurable improvement. I wanted to understand whether we’d picked the wrong change.&lt;/p&gt;

&lt;p&gt;So I grouped facts by how many examples in the second stage taught the model to answer them correctly, then compared accuracy before and after.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Supporting examples per fact&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;Before&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;After&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;Change&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;1–9&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;66.4%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;47.5%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;−18.9 points&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;10–29&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;59.2%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;44.5%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;−14.7 points&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;30 or more&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;52.3%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;39.2%&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;−13.1 points&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;That’s a pretty convincing table. More supporting examples, less damage. Every bucket moves in the expected direction.&lt;/p&gt;

&lt;p&gt;Other cuts agreed. Facts with more refusal examples lost more accuracy. Facts that had received extra examples during a repair pass lost less. I could already see the next experiment: rebalance the examples and check whether recall held up.&lt;/p&gt;

&lt;p&gt;But look at the before column. Every group that lost more also started higher.&lt;/p&gt;

&lt;p&gt;A group starting at 66% has more correct answers available to lose than a group starting at 52%. It also has fewer wrong answers available to fix. Both affect the change score.&lt;/p&gt;

&lt;p&gt;Here’s a small example. Say a training stage keeps 60% of previously correct answers and fixes 20% of previously wrong ones. Apply those same rates to two groups of 100 questions:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;A group starting with 80 correct keeps 48 and gains 4. It finishes at 52, down 28 points.&lt;/li&gt;
  &lt;li&gt;A group starting with 40 correct keeps 24 and gains 12. It finishes at 36, down 4 points.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same retention. Same recovery. A 24-point difference in apparent damage.&lt;/p&gt;

&lt;p&gt;Whatever property you used to define those groups can now look like an explanation for that difference. You don’t need measurement noise to produce it. Those transitions could happen in a completely deterministic system.&lt;/p&gt;

&lt;p&gt;The arithmetic fits on two lines. If starting accuracy is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;A&lt;/code&gt;, retention is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;r&lt;/code&gt;, and recovery is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;g&lt;/code&gt;:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;after  = A*r + (1-A)*g
change = g - A*(1+g-r)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;With fixed retention and recovery rates, higher starting accuracy produces a larger expected drop. The statistical term is mathematical coupling: the starting value is part of the change you’re trying to explain.&lt;/p&gt;

&lt;p&gt;I reran the analysis using the transitions directly. Of the questions the first checkpoint got right, how many did the final model keep right? Of the ones it got wrong, how many did it fix?&lt;/p&gt;

&lt;p&gt;The supporting-example groups retained 61.5%, 58.9%, and 57.0%, respectively. The apparent protective effect disappeared. The direction even flipped, though the differences weren’t statistically significant. The repaired and unrepaired facts retained 58.7% and 59.1%. Almost identical.&lt;/p&gt;

&lt;p&gt;Using the overall retention and recovery rates, a model with just those two parameters reproduced the original bucket changes to within about a point and a half. The groups’ starting accuracies explained most of what had looked like a training-data effect.&lt;/p&gt;

&lt;p&gt;That still leaves room for smaller effects. This analysis couldn’t establish that adding examples never helps. It did take away the evidence I was about to use to justify doing it.&lt;/p&gt;

&lt;p&gt;Question form remained worth investigating. Among initially correct answers, the model retained about 67% on who/what/where-style questions and 50% on yes/no questions. That difference survived the same analysis. We’d already suspected it because of an imbalance in the training data.&lt;/p&gt;

&lt;p&gt;The annoying part is that we had a warning about this general problem in our research notes. It covered groups selected by their outcomes. I’d grouped by training inputs, so I didn’t think the warning applied. It needed to say what to check: groups with different starting accuracies have different opportunities to gain and lose.&lt;/p&gt;

&lt;p&gt;I also gave too much weight to several cuts agreeing. They all used the same change score, and all carried the same baseline problem. Each extra table made me more confident without adding much evidence.&lt;/p&gt;

&lt;p&gt;For the next analysis, I want the counts of answers kept, lost, and recovered alongside the overall score. They’re cheap to compute from the same results. In this case, they would have saved me from planning another training run around a pattern I could reproduce with two probabilities and a subtraction.&lt;/p&gt;
</description>
        <pubDate>Sat, 19 Sep 2026 00:00:00 -0400</pubDate>
        <link>https://jshahbazi.github.io/the-before-column</link>
        <guid isPermaLink="true">https://jshahbazi.github.io/the-before-column</guid>
      </item>
    
      <item>
        <title>Thirty Minutes to Four</title>
        <description>&lt;style&gt;
.entry table {
  margin: 2rem 0;
  font-family: var(--mono);
  font-size: 0.82rem;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.entry table th {
  text-align: right;
  font-weight: 500;
  color: var(--muted);
  border-bottom: 1px solid var(--border-strong);
  padding: 0.35rem 0.9rem;
  font-size: 0.74rem;
  letter-spacing: 0.04em;
}
.entry table th:first-child,
.entry table td:first-child { text-align: left; white-space: normal; }
.entry table td {
  text-align: right;
  padding: 0.5rem 0.9rem;
  border-bottom: 1px solid var(--border);
  color: var(--text-soft);
}
.entry table tbody tr:last-child td { border-bottom: 1px solid var(--border-strong); }
.entry table strong { color: var(--accent); font-weight: 600; }
.entry .caption {
  font-family: var(--sans);
  font-size: 0.78rem;
  color: var(--muted);
  margin-top: -1.4rem;
  margin-bottom: 2rem;
}
&lt;/style&gt;

&lt;p&gt;Every merge to main that touches shared code rebuilds our GPU training image: torch, vLLM, deepspeed, and the CUDA compatibility libraries that let a CUDA 13 build run on the drivers Vertex AI requires. The build runs on Cloud Build because no GitHub-hosted runner has the disk for it, and it took thirty minutes. Every single time. And one morning I even had three of them running at once, and two of the three were only documentation merges (my fault for letting a README.md change trigger the action).&lt;/p&gt;

&lt;p&gt;So I asked Claude Code why it took so long.&lt;/p&gt;

&lt;p&gt;TL;DR: Always ask your advanced AI models &lt;em&gt;why&lt;/em&gt;&lt;/p&gt;

&lt;!--more--&gt;

&lt;h2 id=&quot;finding-out-where-the-time-went&quot;&gt;Finding out where the time went&lt;/h2&gt;

&lt;p&gt;It read the workflow first, which turned out to be a thin shim: authenticate, submit a Cloud Build config, wait. So the question moved to Cloud Build. It pulled the phase timings for the most recent build, then pulled the build log out of Cloud Logging and lined up the timestamp on every Dockerfile step. Then it fetched the image manifest from Artifact Registry and mapped each layer’s compressed size back to the instruction that produced it.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Phase&lt;/th&gt;
      &lt;th&gt;Time&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;GitHub Actions setup&lt;/td&gt;
      &lt;td&gt;0.5 min&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Cloud Build queue&lt;/td&gt;
      &lt;td&gt;0.8 min&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;uv sync&lt;/code&gt; (245 packages)&lt;/td&gt;
      &lt;td&gt;2.2 min&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;vLLM probe, CUDA debs, gcc&lt;/td&gt;
      &lt;td&gt;1.0 min&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;useradd&lt;/code&gt; + &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;chown -R&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;9.2 min&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Push to Artifact Registry (12.4 GB)&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;15.3 min&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Resolve digest, propose pin&lt;/td&gt;
      &lt;td&gt;0.3 min&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Two lines account for 24 of the 29 minutes.&lt;/p&gt;

&lt;h2 id=&quot;a-chown-that-cost-eight-gigabytes&quot;&gt;A chown that cost eight gigabytes&lt;/h2&gt;

&lt;p&gt;The Dockerfile ended the way a lot of Dockerfiles end: create a non-root user, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;chown -R&lt;/code&gt; the virtualenv to it, switch users. Reasonable. Except overlay2 implements a chown by copying every touched file up into a new layer, and the venv held four gigabytes of compressed wheels.&lt;/p&gt;

&lt;p&gt;It was worse than a copy. uv’s wheel cache lived inside the image (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UV_CACHE_DIR=/tmp/uv-cache&lt;/code&gt;), and uv hardlinks the venv to that cache. The chown touched both trees, the copy-up broke the hardlinks, and the new layer held two full copies. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;uv sync&lt;/code&gt; layer was 4.0 GB compressed. The chown layer on top of it was 8.0 GB. The image we pushed was 12.4 GB for about 4.4 GB of payload.&lt;/p&gt;

&lt;p&gt;Claude Code inferred this from the sizes. It never listed the layer’s contents, and it said so. 8,026 MB sitting on top of 4,020 MB is hard to explain any other way, but I appreciated the distinction.&lt;/p&gt;

&lt;p&gt;The push took fifteen minutes because it was pushing that layer. Docker pushes layers in parallel, and the 8 GB one finished last.&lt;/p&gt;

&lt;h2 id=&quot;no-layer-was-ever-reused&quot;&gt;No layer was ever reused&lt;/h2&gt;

&lt;p&gt;The second problem was quieter. Every source directory was copied before &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;uv sync&lt;/code&gt;, so any code change invalidated the dependency install and the 4 GB layer got rebuilt and re-pushed. The Cloud Build config had no &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--cache-from&lt;/code&gt;, so even an unchanged lockfile started from nothing on a fresh worker. In the push log, only the five base-image layers ever said &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Layer already exists&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;README.md&lt;/code&gt; was in the workflow’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;paths:&lt;/code&gt; filter, because the Dockerfile copied it for setuptools’ package metadata. Every docs PR touches the README. So every docs merge rebuilt and pushed 12 GB.&lt;/p&gt;

&lt;h2 id=&quot;the-fixes&quot;&gt;The fixes&lt;/h2&gt;

&lt;p&gt;Claude Code ranked three fixes by payoff. I asked it to implement all three and to double-check its own work.&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Create the user first and let uv install as that user.&lt;/strong&gt; Same venv ownership as before, with no rewrite. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UV_NO_CACHE=1&lt;/code&gt; keeps the wheel cache out of the image entirely.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Install dependencies before copying source.&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;uv sync --no-install-project&lt;/code&gt; from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pyproject.toml&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;uv.lock&lt;/code&gt; alone, then copy the source and run a second &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;uv sync&lt;/code&gt; for the editable install. Cloud Build pulls the previous &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:latest&lt;/code&gt; and builds with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--cache-from&lt;/code&gt; against it.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Stop copying the README.&lt;/strong&gt; setuptools only needs the file to exist. The Dockerfile writes a stub, and README.md came out of the path filter.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The Dockerfile’s shape, before and after:&lt;/p&gt;

&lt;div class=&quot;language-dockerfile highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c&quot;&gt;# Before&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;COPY&lt;/span&gt;&lt;span class=&quot;s&quot;&gt; pyproject.toml uv.lock README.md ./&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;COPY&lt;/span&gt;&lt;span class=&quot;s&quot;&gt; apps/training ./apps/training&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;COPY&lt;/span&gt;&lt;span class=&quot;s&quot;&gt; packages ./packages&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;RUN &lt;/span&gt;uv &lt;span class=&quot;nb&quot;&gt;sync&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--frozen&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--no-dev&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--group&lt;/span&gt; training
&lt;span class=&quot;c&quot;&gt;# ... CUDA compat libs, nvcc, gcc ...&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;RUN &lt;/span&gt;groupadd &lt;span class=&quot;nt&quot;&gt;--system&lt;/span&gt; app &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; useradd &lt;span class=&quot;nt&quot;&gt;--system&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--gid&lt;/span&gt; app app &lt;span class=&quot;se&quot;&gt;\
&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;chown&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-R&lt;/span&gt; app:app /app/.venv /tmp/uv-cache
&lt;span class=&quot;k&quot;&gt;USER&lt;/span&gt;&lt;span class=&quot;s&quot;&gt; app&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-dockerfile highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c&quot;&gt;# After&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;ENV&lt;/span&gt;&lt;span class=&quot;s&quot;&gt; UV_NO_CACHE=1&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;RUN &lt;/span&gt;groupadd &lt;span class=&quot;nt&quot;&gt;--system&lt;/span&gt; app &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; useradd &lt;span class=&quot;nt&quot;&gt;--system&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--gid&lt;/span&gt; app app &lt;span class=&quot;se&quot;&gt;\
&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;mkdir&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-p&lt;/span&gt; /app &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;chown &lt;/span&gt;app:app /app
&lt;span class=&quot;k&quot;&gt;COPY&lt;/span&gt;&lt;span class=&quot;s&quot;&gt; --chown=app:app pyproject.toml uv.lock ./&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;USER&lt;/span&gt;&lt;span class=&quot;s&quot;&gt; app&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;RUN &lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;printf&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;# stub\n&apos;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; README.md &lt;span class=&quot;se&quot;&gt;\
&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; uv &lt;span class=&quot;nb&quot;&gt;sync&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--frozen&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--no-dev&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--group&lt;/span&gt; training &lt;span class=&quot;nt&quot;&gt;--no-install-project&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;USER&lt;/span&gt;&lt;span class=&quot;s&quot;&gt; root&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;# ... CUDA compat libs, nvcc, gcc ...&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;COPY&lt;/span&gt;&lt;span class=&quot;s&quot;&gt; apps/training ./apps/training&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;COPY&lt;/span&gt;&lt;span class=&quot;s&quot;&gt; packages ./packages&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;USER&lt;/span&gt;&lt;span class=&quot;s&quot;&gt; app&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;RUN &lt;/span&gt;uv &lt;span class=&quot;nb&quot;&gt;sync&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--frozen&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--no-dev&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--group&lt;/span&gt; training
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Everything a code change can invalidate now sits below the CUDA layers, so those stay cached too.&lt;/p&gt;

&lt;h2 id=&quot;results&quot;&gt;Results&lt;/h2&gt;

&lt;p&gt;It verified with two live builds on the branch. The first was cold: the previous &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:latest&lt;/code&gt; still had the old layout, so nothing could hit the cache. The second was warm.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Build&lt;/th&gt;
      &lt;th&gt;Cloud Build&lt;/th&gt;
      &lt;th&gt;Image&lt;/th&gt;
      &lt;th&gt;Layers uploaded&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Before&lt;/td&gt;
      &lt;td&gt;28 min&lt;/td&gt;
      &lt;td&gt;12.41 GB&lt;/td&gt;
      &lt;td&gt;11&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Cold (new Dockerfile)&lt;/td&gt;
      &lt;td&gt;18.6 min&lt;/td&gt;
      &lt;td&gt;4.37 GB&lt;/td&gt;
      &lt;td&gt;11&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Warm&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;2 min 20 s&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;4.37 GB&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;The cold build spent five minutes pulling the old 12 GB image one last time, built in five and a half with the chown gone, and pushed the new 4 GB dependency layer in eight. The warm build hit the cache on all nineteen Dockerfile steps, uploaded nothing, and produced a byte-identical digest. Its entire cost was two minutes pulling the 4.4 GB cache image. End to end, the GitHub Actions run went from thirty minutes to a little over four.&lt;/p&gt;

&lt;p&gt;The warm build was also a better test than I had planned. A teammate’s docs pass landed on the branch while the cold build was running, so the commit it built changed the README and some comments and nothing else. That is exactly the case that used to cost thirty minutes.&lt;/p&gt;

&lt;h2 id=&quot;double-checking&quot;&gt;Double-checking&lt;/h2&gt;

&lt;p&gt;“Double check your work” turned out to be worth saying. It re-read the final Dockerfile, ran the contract tests it had added (layer order, no recursive chown, no shipped cache, the cache warm-up step), and trial-merged both branches onto main. Then it went back to the registry and streamed the tar headers of the pushed layers to confirm the venv and the editable install were owned by the runtime user and the source trees were still root-owned, which is what the old chown had produced.&lt;/p&gt;

&lt;p&gt;It also caught two of its own mistakes on the way. It had read the tar link-count column as the uid and streamed the wrong layer on the first pass. And a comment a teammate had reworded was accurate for one branch but would become false the moment the companion branch merged. It fixed both and said which.&lt;/p&gt;

&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Map layer sizes to instructions before touching anything.&lt;/strong&gt; The registry manifest plus the image config’s history gives you a bill of materials per Dockerfile line. The build log never showed the 8 GB layer. That table put it on the first row.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;chown -R&lt;/code&gt; is a copy.&lt;/strong&gt; On overlay2 it rewrites every file it touches into a new layer. Create the user first and install as that user, or leave a root-owned, world-readable venv alone.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;A layer cache is a contract about instruction order.&lt;/strong&gt; Dependencies keyed on the lockfile, source after. Without &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--cache-from&lt;/code&gt; on a fresh worker, none of it matters.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Path filters are code.&lt;/strong&gt; README.md sat in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;paths:&lt;/code&gt; because a Dockerfile copied it. Nobody wrote that down as a decision, and it cost thirty minutes per docs merge.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Ask for verification.&lt;/strong&gt; The fix was ordinary Docker lore. The loop paid for itself: dispatch, watch, read the log, inspect the layers, find the stale comment.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;final-thoughts&quot;&gt;Final thoughts&lt;/h2&gt;

&lt;p&gt;Every one of these fixes is in some Docker best-practices document, and I have written variants of that chown line myself. The diagnosis is what I want to remember: four systems (GitHub Actions, Cloud Build, Cloud Logging, Artifact Registry), one sitting, and one thread of reasoning that went from “this action is slow” to “this specific line produced this specific eight-gigabyte layer” with the evidence attached. I would not have sat down to do that between other things. The model did it well.&lt;/p&gt;
</description>
        <pubDate>Thu, 17 Sep 2026 00:00:00 -0400</pubDate>
        <link>https://jshahbazi.github.io/Thirty-Minutes-to-Four</link>
        <guid isPermaLink="true">https://jshahbazi.github.io/Thirty-Minutes-to-Four</guid>
      </item>
    
      <item>
        <title>Same Answers, Different Score</title>
        <description>&lt;style&gt;
.entry table {
  margin: 2rem 0;
  font-family: var(--mono);
  font-size: 0.82rem;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.entry table th {
  text-align: right;
  font-weight: 500;
  color: var(--muted);
  border-bottom: 1px solid var(--border-strong);
  padding: 0.35rem 0.9rem;
  font-size: 0.74rem;
  letter-spacing: 0.04em;
}
.entry table th:first-child,
.entry table td:first-child { text-align: left; white-space: normal; }
.entry table td {
  text-align: right;
  padding: 0.5rem 0.9rem;
  border-bottom: 1px solid var(--border);
  color: var(--text-soft);
}
.entry table tbody tr:last-child td { border-bottom: 1px solid var(--border-strong); }
.entry table strong { color: var(--accent); font-weight: 600; }
.entry .caption {
  font-family: var(--sans);
  font-size: 0.78rem;
  color: var(--muted);
  margin-top: -1.4rem;
  margin-bottom: 2rem;
}
&lt;/style&gt;

&lt;p&gt;We fine-tune small models on customer documents to answer domain-specific questions. To evaluate how well it works, we run a few thousand questions through it and have a frontier model grade the answers. That score determines whether a model checkpoint is ready to ship.&lt;/p&gt;

&lt;p&gt;Last week I upgraded our external LLM calls from Sonnet 4.5 and Opus 4.5 to Sonnet 5 and Opus 5. The model we train remained completely unchanged, and I expected the grader upgrade to be neutral for our benchmark scores.&lt;/p&gt;

&lt;!--more--&gt;

&lt;p&gt;To verify, I re-graded a set of saved responses using the exact same response files. Only the grader model changed.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Probe set&lt;/th&gt;
      &lt;th&gt;Sonnet 4.5&lt;/th&gt;
      &lt;th&gt;Sonnet 5&lt;/th&gt;
      &lt;th&gt;Δ&lt;/th&gt;
      &lt;th&gt;Flipped&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Varied phrasings (880)&lt;/td&gt;
      &lt;td&gt;0.599&lt;/td&gt;
      &lt;td&gt;0.662&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;+0.064&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;7.7%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Terse user phrasings (880)&lt;/td&gt;
      &lt;td&gt;0.406&lt;/td&gt;
      &lt;td&gt;0.469&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;+0.064&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;9.3%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Adversarial contamination (100)&lt;/td&gt;
      &lt;td&gt;0.930&lt;/td&gt;
      &lt;td&gt;0.920&lt;/td&gt;
      &lt;td&gt;−0.010&lt;/td&gt;
      &lt;td&gt;1.0%&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Both independent probe sets jumped by the exact same margin, and almost every flip went upward (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;incorrect&lt;/code&gt; to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;partial&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;partial&lt;/code&gt; to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;correct&lt;/code&gt;). Sonnet 5 simply graded the same text more leniently.&lt;/p&gt;

&lt;p&gt;For comparison, grading the same responses twice with the &lt;em&gt;same&lt;/em&gt; model usually flips around 1.1% of verdicts - essentially our noise floor. Here, flips were 7–8x higher and entirely directional.&lt;/p&gt;

&lt;p&gt;There was also a potential surprise: Sonnet 5 rejects explicit &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;temperature&lt;/code&gt; parameters, so the re-grade ran with the provider’s default temperature. Re-running the old model at that same default shifted scores by only +0.002 (six flips, well inside noise). The model upgrade itself accounted for +0.061 of the +0.064 jump.&lt;/p&gt;

&lt;h2 id=&quot;whoever-writes-your-eval-questions-is-making-a-claim-about-your-users&quot;&gt;Whoever writes your eval questions is making a claim about your users&lt;/h2&gt;

&lt;p&gt;While digging into this, I ran another test on question formulation using the same model and 220 target facts across five different question sources.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Who wrote the question&lt;/th&gt;
      &lt;th&gt;Recall&lt;/th&gt;
      &lt;th&gt;Declined&lt;/th&gt;
      &lt;th&gt;Median words&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Verbatim from training&lt;/td&gt;
      &lt;td&gt;0.966&lt;/td&gt;
      &lt;td&gt;0.000&lt;/td&gt;
      &lt;td&gt;19&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Our eval generator&lt;/td&gt;
      &lt;td&gt;0.807&lt;/td&gt;
      &lt;td&gt;0.048&lt;/td&gt;
      &lt;td&gt;18&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;A different generator&lt;/td&gt;
      &lt;td&gt;0.599&lt;/td&gt;
      &lt;td&gt;0.173&lt;/td&gt;
      &lt;td&gt;14&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Wh-questions only&lt;/td&gt;
      &lt;td&gt;0.714&lt;/td&gt;
      &lt;td&gt;0.107&lt;/td&gt;
      &lt;td&gt;14&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;How people actually type&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;0.406&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;0.325&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;7&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p class=&quot;caption&quot;&gt;&quot;How people actually type&quot; = an LLM prompted to write the question a busy person would type into an internal assistant: terse, context omitted, sometimes a request rather than a question.&lt;/p&gt;

&lt;p&gt;We had been reporting an eval recall around 0.89. The model definitely knows these facts - it scores 0.966 when prompted using the exact phrasing it saw during training. But when asked those same facts in seven-word prompts resembling real user queries, recall drops to 41%, and the model declines to answer roughly a third of the time.&lt;/p&gt;

&lt;p&gt;A metric like “overall recall” doesn’t mean much without specifying how the questions were generated. Testing multiple generation styles is important to surface any limitations.&lt;/p&gt;

&lt;h2 id=&quot;treating-graders-like-instruments&quot;&gt;Treating graders like instruments&lt;/h2&gt;

&lt;p&gt;The immediate fixes took under an hour, but the more important takeaway was how easily unversioned evaluation tooling can distort results.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Pin the grader version in eval metadata.&lt;/strong&gt; If you version probe sets and stability baselines, the grader model belongs in that same configuration so changes require an explicit re-baseline.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Measure baseline variance.&lt;/strong&gt; Grading the same responses twice with the same model establishes an noise floor. Without knowing our baseline ~1% flip rate, that 7.7% difference could easily have been mistaken for genuine model improvement.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Avoid unpinned model defaults in configs.&lt;/strong&gt; Our default model string had been updated weeks earlier, and we only avoided accidental shifts because deployment manifests happened to pin the old version explicitly.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Smoke-test parameter compatibility.&lt;/strong&gt; Checking whether an API key works with a new model isn’t enough; client wrappers need end-to-end tests with the exact payload used in production.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Vary question generation styles.&lt;/strong&gt; If benchmark questions are generated with the same prompts and model families used for training data, the benchmark often ends up evaluating what it has seen during training instead of actual comprehension.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;final-thoughts&quot;&gt;Final thoughts&lt;/h2&gt;

&lt;p&gt;Language models end up in important areas all over the pipeline: grading responses, writing test questions, extracting facts. None of them face the customer, but they quietly drive every decision about what ships.  The littlest thing can ripple through the system and cause unexpected shifts in outcomes, so it’s crucial to maintain rigorous versioning, testing, and monitoring practices throughout the entire workflow.&lt;/p&gt;
</description>
        <pubDate>Tue, 08 Sep 2026 00:00:00 -0400</pubDate>
        <link>https://jshahbazi.github.io/Same-Answers-Different-Score</link>
        <guid isPermaLink="true">https://jshahbazi.github.io/Same-Answers-Different-Score</guid>
      </item>
    
  </channel>
</rss>