<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Karlis Gutans</title>
    <description>The latest articles on DEV Community by Karlis Gutans (@kargut).</description>
    <link>https://dev.to/kargut</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4118283%2F666bacc6-481f-4ee6-993c-fdf366489604.png</url>
      <title>DEV Community: Karlis Gutans</title>
      <link>https://dev.to/kargut</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kargut"/>
    <language>en</language>
    <item>
      <title>I automated my refactoring method with GitLab Duo agents. The agent writes the fix, a CI gate decides the merge.</title>
      <dc:creator>Karlis Gutans</dc:creator>
      <pubDate>Tue, 20 Oct 2026 12:32:18 +0000</pubDate>
      <link>https://dev.to/kargut/i-automated-my-refactoring-method-with-gitlab-duo-agents-the-agent-writes-the-fix-a-ci-gate-2bam</link>
      <guid>https://dev.to/kargut/i-automated-my-refactoring-method-with-gitlab-duo-agents-the-agent-writes-the-fix-a-ci-gate-2bam</guid>
      <description>&lt;p&gt;AI writes code fast. The code runs and passes the tests. It is still hard to change.&lt;/p&gt;

&lt;p&gt;I refactor a lot of AI-generated code. The same problems come back every time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Functions grow long and deeply nested.&lt;/li&gt;
&lt;li&gt;Files collect responsibilities that belong somewhere else.&lt;/li&gt;
&lt;li&gt;The next AI session needs more and more context to make a safe edit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the GitLab Transcend Hackathon (theme: &lt;strong&gt;Life After Code&lt;/strong&gt;) I tested how far my own refactoring process can be automated with GitLab pipelines and the GitLab Duo Agent Platform. The result is &lt;strong&gt;Liepa refactoring&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Devpost: &lt;a href="https://devpost.com/software/liepa-refactoring" rel="noopener noreferrer"&gt;https://devpost.com/software/liepa-refactoring&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Code: &lt;a href="https://gitlab.com/gitlab-ai-hackathon/transcend-october-2026/43312097/liepa-refactoring" rel="noopener noreferrer"&gt;https://gitlab.com/gitlab-ai-hackathon/transcend-october-2026/43312097/liepa-refactoring&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  My manual method
&lt;/h2&gt;

&lt;p&gt;My method has three steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Take the most characteristic keywords of each file.&lt;/li&gt;
&lt;li&gt;Check whether those keywords belong to the area of the app where the file lives.&lt;/li&gt;
&lt;li&gt;If they do not, the file mixes responsibilities. Refactor it with an AI assistant, one file at a time.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It works. It is also slow, and it depends on me remembering to do it.&lt;/p&gt;

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

&lt;p&gt;Liepa refactoring has three parts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;liepa scan&lt;/code&gt;:&lt;/strong&gt; a Python scanner. It runs standard checks (function length, nesting, cyclomatic complexity, duplicated function bodies) and gives every file an &lt;strong&gt;AI-readiness score&lt;/strong&gt; from 0 to 100.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A refactor flow:&lt;/strong&gt; a GitLab Duo agent flow. It scans the project, picks the lowest-scoring file and acts on that one file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A control dial:&lt;/strong&gt; a config file that sets how much control is handed to the agent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The scanner uses the Python standard library only: &lt;code&gt;ast&lt;/code&gt;, &lt;code&gt;keyword&lt;/code&gt;, &lt;code&gt;collections&lt;/code&gt;. No runtime dependencies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning the keyword method into code
&lt;/h2&gt;

&lt;p&gt;The AI-readiness score has seven checks. Six are familiar: context size, function length, nesting, complexity, docstrings and import fan-out. The seventh, &lt;code&gt;keyword_areas&lt;/code&gt;, is my manual method.&lt;/p&gt;

&lt;p&gt;How it works:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Collect the names in a file: functions, classes, arguments, attributes. Split &lt;code&gt;snake_case&lt;/code&gt; and &lt;code&gt;CamelCase&lt;/code&gt; into words. Drop Python keywords, builtins and generic words like &lt;code&gt;data&lt;/code&gt; or &lt;code&gt;value&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Keep the 7 most frequent words.&lt;/li&gt;
&lt;li&gt;The area of a file is its directory.&lt;/li&gt;
&lt;li&gt;For every word, find its home area: the directory where the word is relatively most frequent across the whole project.&lt;/li&gt;
&lt;li&gt;The penalty is &lt;code&gt;10 × (keywords whose home is elsewhere) / 7&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is a real result from the tool's own CLI file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/liepa/cli.py  91
`main` has complexity 12 (limit 10); top keywords span 2 areas:
path (src/liepa), config (src/liepa), score (src/liepa), parser (src/liepa),
add (src/liepa/checks), argument (src/liepa), files (src/liepa);
consider splitting the file
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One keyword out of seven lives in another area. The penalty is small: 1.4 points. That is correct. It is a hint to look, not a verdict.&lt;/p&gt;

&lt;h2&gt;
  
  
  The control dial
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;.refactor-agent.yml&lt;/code&gt; lives in the repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;suggest&lt;/span&gt; &lt;span class="c1"&gt;# suggest | propose | auto&lt;/span&gt;

&lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;src"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;examples/messy/liepa_imin"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;thresholds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;min_score&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;
  &lt;span class="na"&gt;max_function_lines&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;50&lt;/span&gt;
  &lt;span class="na"&gt;max_nesting&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt;
  &lt;span class="na"&gt;max_complexity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;

&lt;span class="na"&gt;auto&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;require_score_improvement&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;require_passing_tests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;max_changed_lines&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The three modes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;suggest:&lt;/strong&gt; the agent posts one refactoring suggestion on the merge request. No files change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;propose:&lt;/strong&gt; the agent refactors the file, runs &lt;code&gt;pytest&lt;/code&gt; and &lt;code&gt;ruff&lt;/code&gt;, re-scans, and opens a merge request for a human to review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;auto:&lt;/strong&gt; the agent does the same as in propose. Then a CI job decides whether the merge request merges.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The agent never decides the merge
&lt;/h2&gt;

&lt;p&gt;This is the most important design decision. In auto mode the LLM only opens the merge request. A small deterministic job, &lt;code&gt;refactor_auto_merge&lt;/code&gt;, checks five conditions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Only refactorable Python files changed. No tests, CI files, config or flows.&lt;/li&gt;
&lt;li&gt;Every changed file scores strictly higher than before.&lt;/li&gt;
&lt;li&gt;The change is under 200 added plus deleted lines.&lt;/li&gt;
&lt;li&gt;The pipeline is green. GitLab merges only when it succeeds.&lt;/li&gt;
&lt;li&gt;The merge request head is the exact commit that was scored.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The gate posts its decision on the merge request. This is the real note from the demo run:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Condition&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;th&gt;Details&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Only refactorable files changed&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;1 changed files, all scanned Python files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI-readiness score improves&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;reports.py&lt;/code&gt;: 40 → 51&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Change size&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;124 changed lines, limit 200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pipeline green&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;enforced by GitLab&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Head SHA unchanged&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;checked &lt;code&gt;0dc870fc4e71&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The gate and its limits are read from the &lt;strong&gt;target branch&lt;/strong&gt;, not from the merge request. A refactor branch cannot loosen the rules that judge it.&lt;/p&gt;

&lt;p&gt;This was not my first version. Duo Code Review found that the first auto mode trusted the merge request's own config and code. A refactor could have edited the limits that judged it. The fix: run the gate from a git worktree of the target branch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The demo
&lt;/h2&gt;

&lt;p&gt;I needed a codebase bad enough to give the agent real work. So a Duo Developer agent built &lt;strong&gt;liepa-imin&lt;/strong&gt;: a toy insurance back office with parties, policies, claims, renewals and reports. Its &lt;code&gt;reports.py&lt;/code&gt; scored 29: a 92-line function, complexity 20, 6 levels of nesting and 17 imports. Behaviour tests and golden files guard the output.&lt;/p&gt;

&lt;p&gt;All three modes ran on the same file:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;What happened&lt;/th&gt;
&lt;th&gt;Score&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;suggest&lt;/td&gt;
&lt;td&gt;one comment with three refactoring steps&lt;/td&gt;
&lt;td&gt;28 → 28&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;propose&lt;/td&gt;
&lt;td&gt;refactor MR, reviewed and merged by me&lt;/td&gt;
&lt;td&gt;28 → 40&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;auto&lt;/td&gt;
&lt;td&gt;refactor MR, merged by the gate with no human review&lt;/td&gt;
&lt;td&gt;40 → 51&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The auto merge request merged about one minute after it was opened.&lt;/p&gt;

&lt;h2&gt;
  
  
  What did not work
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Triggers need a human.&lt;/strong&gt; A GitLab flow trigger fires only on a person's action. A CI job, a bot or another flow cannot start it. GitLab staff confirmed this in the hackathon Discord. Every demo run was started by hand from the Duo CLI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The first auto pipeline was rejected.&lt;/strong&gt; The gate job needed jobs that existed only in branch pipelines. I moved lint, tests and scan into merge request pipelines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The sandbox has no PyPI access.&lt;/strong&gt; The GitLab-hosted sandbox cannot install packages, so &lt;code&gt;propose&lt;/code&gt; runs locally for now.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The score can be gamed.&lt;/strong&gt; Docstrings and split functions raise the score without always improving the design. The size limit and the one-file rule reduce this. They do not remove it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why this works
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The agent writes, the code decides.&lt;/strong&gt; An LLM is good at producing a refactor. A small deterministic gate is better at deciding whether it may merge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small steps.&lt;/strong&gt; One file per run keeps every change reviewable, even in auto mode.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Evidence over claims.&lt;/strong&gt; Every agent session, job and merge request is linked in the README. It is clear what ran and what is still untested.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What is next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Publish the flow and add a trigger, so assigning a reviewer starts it.&lt;/li&gt;
&lt;li&gt;Run the dry run and a refused case on a real merge request.&lt;/li&gt;
&lt;li&gt;Give the bot token its own least-privilege identity.&lt;/li&gt;
&lt;li&gt;Keep running the flow until &lt;code&gt;reports.py&lt;/code&gt; reaches 60.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;How do you refactor AI-generated code? How much control would you hand to an agent that does it for you?&lt;/p&gt;

</description>
      <category>gitlab</category>
      <category>ai</category>
      <category>python</category>
      <category>refactoring</category>
    </item>
    <item>
      <title>Give an LLM no facts and ask for a job ad. What does it commit to?</title>
      <dc:creator>Karlis Gutans</dc:creator>
      <pubDate>Mon, 05 Oct 2026 08:11:03 +0000</pubDate>
      <link>https://dev.to/kargut/give-an-llm-no-facts-and-ask-for-a-job-ad-what-does-it-commit-to-2ma5</link>
      <guid>https://dev.to/kargut/give-an-llm-no-facts-and-ask-for-a-job-ad-what-does-it-commit-to-2ma5</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/kaggle-2026-09-23"&gt;Kaggle Benchmarking Challenge&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update, 7 October 2026:&lt;/strong&gt; the benchmark now runs all 20 briefs. The one-brief results below did not hold up. See Update: 20 briefs at the end.&lt;/p&gt;

&lt;p&gt;Job ads are long. The first one I read end to end. After that I scan. I look for the concrete parts: pay, the stack, the team size, where the work happens, what on-call looks like. Everything else is filler I have learned to skip.&lt;/p&gt;

&lt;p&gt;I also do not apply because I match every line. I apply when something concrete catches my attention.&lt;/p&gt;

&lt;p&gt;More and more of these ads are now drafted by LLMs. So I wanted to know: when a model writes a job ad and is given no facts at all, what does it commit to?&lt;/p&gt;

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

&lt;p&gt;My main goal was to try Kaggle Benchmarks. I already had a scoring method from an earlier project, so I ported it into a Kaggle notebook. In about two days it went from an empty notebook to a public leaderboard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The task.&lt;/strong&gt; Every model gets the same brief: a role, a seniority level, and the topics the ad must cover. The brief on the current leaderboard is a junior backend engineer. The ad must cover the service, languages and data stores, code review, team size, location, pay, and the first six months. The brief names no employer and supplies no facts.&lt;/p&gt;

&lt;p&gt;The model has three options for every topic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Invent a specific:&lt;/strong&gt; "€45,000–55,000", "Go and PostgreSQL", "a team of six".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leave a blank:&lt;/strong&gt; "[salary range]".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write around it:&lt;/strong&gt; "competitive salary", "a modern stack".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The judge.&lt;/strong&gt; A fixed judge model (Claude Sonnet 4.5, never a contestant) runs two stages:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Extraction:&lt;/strong&gt; split the ad into claims, each one an exact substring of the ad.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Classification:&lt;/strong&gt; label each claim with one tier.

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Concrete:&lt;/strong&gt; you can quote a particular. A number, a named tool, a place, a cadence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;General direction:&lt;/strong&gt; real intent, nothing checkable. "Flexible hours."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Empty slogan:&lt;/strong&gt; fits any ad at any company. "Fast-paced environment."&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;The score&lt;/strong&gt; is concrete claims divided by all claims, from 0 to 1.&lt;/p&gt;

&lt;p&gt;The judge is not trusted on its own. Code enforces the rules after every call:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A claim that is not a verbatim substring of the ad is rejected.&lt;/li&gt;
&lt;li&gt;A "concrete" label must quote its particular, and that quote must appear in the claim. If not, the claim is demoted to general direction.&lt;/li&gt;
&lt;li&gt;Template blanks like "[salary range]" or "€XX,XXX" are flagged and never count as concrete.&lt;/li&gt;
&lt;li&gt;"English" and bare skill nouns like "communication skills" never count as a particular.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Higher is not better.&lt;/strong&gt; The brief contains no facts, so every concrete claim is made up. The score measures how willing a model is to commit to specifics. Which behaviour you want depends on whether a human fills in the real facts afterwards.&lt;/p&gt;

&lt;p&gt;The tier rules come from my earlier project, which scored real job postings. There I measured my own labeling consistency before trusting any model number: &lt;a href="https://dev.to/kargut/before-you-report-llm-agreement-measure-the-human-twice-1fn5"&gt;Before you report LLM agreement, measure the human twice&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Models Tested
&lt;/h2&gt;

&lt;p&gt;I picked the small, fast tier from each vendor. These are the models a company would plug into an HR tool to draft ads at volume. Cost and speed matter there more than peak quality.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Score&lt;/th&gt;
&lt;th&gt;Cost per run&lt;/th&gt;
&lt;th&gt;Time per run&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Haiku 4.5&lt;/td&gt;
&lt;td&gt;0.50&lt;/td&gt;
&lt;td&gt;$0.037&lt;/td&gt;
&lt;td&gt;29 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.7 Flash&lt;/td&gt;
&lt;td&gt;0.48&lt;/td&gt;
&lt;td&gt;$0.062&lt;/td&gt;
&lt;td&gt;44 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 2.5 Flash&lt;/td&gt;
&lt;td&gt;0.37&lt;/td&gt;
&lt;td&gt;$0.074&lt;/td&gt;
&lt;td&gt;60 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.4 mini&lt;/td&gt;
&lt;td&gt;0.35&lt;/td&gt;
&lt;td&gt;$0.069&lt;/td&gt;
&lt;td&gt;45 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Grok 4.5&lt;/td&gt;
&lt;td&gt;error&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Cost and time are as Kaggle reports them for one run: the ad plus both judge calls. Most of the token count is the judge, not the model under test.&lt;/p&gt;

&lt;p&gt;The judge is kept out of the contest on purpose. A model grading its own family's writing is a conflict I did not want to explain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Findings
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What one ad looks like.&lt;/strong&gt; Gemini 3.7 Flash wrote a 2,528-character ad. The judge extracted 29 claims:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;14 concrete&lt;/li&gt;
&lt;li&gt;13 general direction&lt;/li&gt;
&lt;li&gt;2 empty slogans&lt;/li&gt;
&lt;li&gt;0 placeholders&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No blanks at all. Every topic got either an invented specific or a soft phrase. It never wrote "[salary range]".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The leaderboard splits into two pairs.&lt;/strong&gt; Claude Haiku 4.5 and Gemini 3.7 Flash commit to specifics in about half their claims. Gemini 2.5 Flash and GPT-5.4 mini sit around a third.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not read it as a ranking yet.&lt;/strong&gt; The current leaderboard runs one brief, so each score is one ad of roughly 30 claims. For Gemini 3.7 Flash, 14 concrete out of 29 gives a 95% interval of about 0.31 to 0.66. Every model on the board sits inside that interval. The two pairs are a direction to check, not a result.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The judge is unmeasured on these ads.&lt;/strong&gt; On real postings, an earlier version of the rules reached 86.7% agreement with my hand labels on the concrete/not-concrete boundary. That was a different judge model and older prompts. Nobody has hand-checked this judge on generated ads.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Grok 4.5 failed, and that is by design.&lt;/strong&gt; The task refuses to publish a score unless every brief is scored. A partial average across a subset of briefs is a different benchmark, and it would sit on the same leaderboard as if it were not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What surprised me on the Kaggle side:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A task must return a number, bool or dict. Returning a string fails to store.&lt;/li&gt;
&lt;li&gt;Reasoning models put &lt;code&gt;&amp;lt;think&amp;gt;&lt;/code&gt; text before their JSON. The built-in schema parser then fails, so the judge parses JSON itself.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;%choose&lt;/code&gt; keeps only the latest run of the main task. Run it once in the notebook, then add models from the task page.&lt;/li&gt;
&lt;li&gt;Model names contain &lt;code&gt;/&lt;/code&gt;. Sanitize them before using them in file names.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why model choice matters here.&lt;/strong&gt; Ads are many and they are long. Getting from an ad to its actual claims takes effort, and that effort goes to a model on both sides. The employer's model decides what gets committed. The candidate's model decides what counts as concrete. Pick the wrong one on the writing side and you get invented salaries. Pick the wrong one on the reading side and "competitive salary" passes as a commitment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I would measure next:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;All 20 briefs, several samples each.&lt;/strong&gt; The briefs already cover intern to principal, from frontend to production support. One ad per model is not enough.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A hand check of the judge&lt;/strong&gt; on a sample of generated claims, the same way I checked it on real postings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The split per topic.&lt;/strong&gt; Pay is the interesting one: invented number, placeholder, or "competitive".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whether the ad attracts the right people.&lt;/strong&gt; This is the metric that matters most, and specificity is only one input to it. It needs outcome data: who applied, and how many were qualified. That data sits with employers and job boards, not in a public benchmark.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  My Benchmark
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.kaggle.com/benchmarks/karlisgutans/job-ad-specificity-what-llms-commit-to/leaderboard" rel="noopener noreferrer"&gt;Kaggle benchmark&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Briefs, prompts and judge code are in the notebook. The method behind the tiers is in the repo: &lt;a href="https://github.com/kargut/job-posting-specificity" rel="noopener noreferrer"&gt;github.com/kargut/job-posting-specificity&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;When you read a job ad, what makes you apply: the concrete details, or something else entirely?&lt;/p&gt;

&lt;h2&gt;
  
  
  Update: 20 briefs
&lt;/h2&gt;

&lt;p&gt;The first leaderboard ran one brief. I wrote that it was a direction to check, not a result. So I checked it. I ran all 20 briefs, from intern to principal, with the same judge and the same prompts.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.kaggle.com/benchmarks/karlisgutans/job-ad-specificity-20-what-llms-commit-to/leaderboard" rel="noopener noreferrer"&gt;https://www.kaggle.com/benchmarks/karlisgutans/job-ad-specificity-20-what-llms-commit-to/leaderboard&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;1 brief&lt;/th&gt;
&lt;th&gt;20 briefs&lt;/th&gt;
&lt;th&gt;Cost, 20 briefs&lt;/th&gt;
&lt;th&gt;Time, 20 briefs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.7 Flash&lt;/td&gt;
&lt;td&gt;0.48&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.48&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$1.31&lt;/td&gt;
&lt;td&gt;16 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Haiku 4.5&lt;/td&gt;
&lt;td&gt;0.50&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.37&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$0.91&lt;/td&gt;
&lt;td&gt;12 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 2.5 Flash&lt;/td&gt;
&lt;td&gt;0.37&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.28&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$1.49&lt;/td&gt;
&lt;td&gt;21 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.4 mini&lt;/td&gt;
&lt;td&gt;0.35&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.19&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$1.19&lt;/td&gt;
&lt;td&gt;14 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Grok 4.5&lt;/td&gt;
&lt;td&gt;error&lt;/td&gt;
&lt;td&gt;error&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Cost and time cover the full run: 20 ads plus 40 judge calls, as Kaggle reports them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What changed:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The two pairs are gone.&lt;/strong&gt; One ad put Claude Haiku 4.5 and Gemini 3.7 Flash level at about half. Over 20 ads, the four models sit on four separate steps, from 0.48 down to 0.19.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only one model held still.&lt;/strong&gt; Gemini 3.7 Flash scored 0.48 on one ad and 0.48 on twenty. Every other model dropped. GPT-5.4 mini dropped the most, from 0.35 to 0.19.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The spread is wide.&lt;/strong&gt; Gemini 3.7 Flash makes almost one claim in two concrete. GPT-5.4 mini makes fewer than one in five. Same briefs, same judge, and a 2.5 times difference in how much each model invents.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The first brief was an easy one to be concrete about.&lt;/strong&gt; It was a junior backend engineer, and it asked for languages and data stores. That topic invites named tools. A brief for an engineering manager or a technical writer gives a model less to name. This is my reading, not a measured result: I have not yet broken the scores down per brief.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grok 4.5 failed again.&lt;/strong&gt; The task publishes no score unless every brief is scored. At least one Grok brief failed in both runs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What it cost.&lt;/strong&gt; A full run cost $0.91 to $1.49 per model and took 12 to 21 minutes. Claude Haiku 4.5 was both the cheapest and the fastest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson.&lt;/strong&gt; The first post already said not to read one ad as a ranking. The 20-brief run shows why. One ad per model is a demo. Twenty is the start of a benchmark.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Still open:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The spread across briefs, so the gaps between models get an interval.&lt;/li&gt;
&lt;li&gt;What each model does with pay: an invented number, a placeholder, or "competitive".&lt;/li&gt;
&lt;li&gt;A hand check of the judge on generated ads.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>devchallenge</category>
      <category>kagglechallenge</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Before you report LLM agreement, measure the human twice</title>
      <dc:creator>Karlis Gutans</dc:creator>
      <pubDate>Mon, 14 Sep 2026 10:00:41 +0000</pubDate>
      <link>https://dev.to/kargut/before-you-report-llm-agreement-measure-the-human-twice-1fn5</link>
      <guid>https://dev.to/kargut/before-you-report-llm-agreement-measure-the-human-twice-1fn5</guid>
      <description>&lt;p&gt;I set out to score how much of a job ad is a real commitment. The number I got is not the interesting part. The interesting part is that I agreed with myself only 75% of the time until I wrote the rule down.&lt;/p&gt;

&lt;p&gt;"Competitive salary." "Fast-paced environment." "You will own your work." Delete those sentences and the ad loses no information. Next to them sit real commitments: a salary band, a named database, two days a week in the office, on-call one week in six.&lt;/p&gt;

&lt;p&gt;The extract-classify-score loop was a weekend. Getting a number I would stand behind was not.&lt;/p&gt;

&lt;p&gt;Code, prompts and the full evaluation report: &lt;a href="https://github.com/kargut/job-posting-specificity" rel="noopener noreferrer"&gt;github.com/kargut/job-posting-specificity&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What a claim looks like
&lt;/h2&gt;

&lt;p&gt;Three stages, deliberately separate so each can be evaluated on its own:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;How&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 — Extraction&lt;/td&gt;
&lt;td&gt;Split a posting into discrete claims&lt;/td&gt;
&lt;td&gt;LLM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 — Classification&lt;/td&gt;
&lt;td&gt;Assign each claim a tier&lt;/td&gt;
&lt;td&gt;LLM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 — Aggregation&lt;/td&gt;
&lt;td&gt;Scores and rollups&lt;/td&gt;
&lt;td&gt;Deterministic Python&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tier 1 — Concrete:&lt;/strong&gt; contains a number, a named technology, a timeframe, or a falsifiable commitment. &lt;code&gt;EUR 60k&lt;/code&gt;. &lt;code&gt;Go and PostgreSQL&lt;/code&gt;. &lt;code&gt;team of nine&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tier 2 — General direction:&lt;/strong&gt; real intent, no checkable detail. &lt;code&gt;We invest in developer growth&lt;/code&gt;. &lt;code&gt;modern stack&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tier 3 — Empty slogan:&lt;/strong&gt; would fit any ad for any job at any company. &lt;code&gt;fast-paced&lt;/code&gt;. &lt;code&gt;rockstar developer&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Score: &lt;code&gt;tier_1 / all scored claims&lt;/code&gt;, per posting. A proportion rather than a count, so a 200-word ad and a 2,000-word ad stay comparable.&lt;/p&gt;

&lt;p&gt;On 15 ads, about 4 in 10 claims were checkable — and that number is an overestimate. The gold set is 15 postings, 150 claims, hand-labeled blind, sampled from public Greenhouse job board APIs. No scraping, and no company is named anywhere in the results. Per posting the score runs 0.00 to 0.90, median 0.30, so the metric does separate documents. I am not quoting the 0.407 gold-set mean as a measurement of how specific job ads are. The rest of this post is why that figure is inflated.&lt;/p&gt;

&lt;p&gt;Four claims, anonymised, under the rule as written:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Claim&lt;/th&gt;
&lt;th&gt;What you can quote&lt;/th&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;EUR 60–75k, band 4&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EUR 60–75k&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;We invest in developer growth&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Excellent written and verbal communication skills in English&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;English&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1 — a named language, not a particular about this job&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fast-paced environment&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;never extracted: Stage 1 drops slogans as atmosphere&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The third row is a real gold label. The fourth never reaches the annotator at all. Both are why "about 4 in 10" overstates how much of an ad is a checkable commitment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measure the human twice
&lt;/h2&gt;

&lt;p&gt;The standard recipe for an LLM evaluation is to hand-label a gold set and report agreement. I did that. Then, before writing down a single accuracy number, I labeled 36 of the same claims a second time, a day apart, blind to my first pass.&lt;/p&gt;

&lt;p&gt;I agreed with myself on &lt;strong&gt;75.0%&lt;/strong&gt; of exact tiers and &lt;strong&gt;80.6%&lt;/strong&gt; of the Tier 1 boundary — the distinction the score actually rests on.&lt;/p&gt;

&lt;p&gt;The specificity score of that one posting moved from 0.333 to 0.472 between passes. Forty-two percent relative movement, on a document that had not changed a character.&lt;/p&gt;

&lt;p&gt;My quality gate was "at least 80% on the Tier 1 boundary". The gate was sitting on the noise floor. A model clearing it would have been indistinguishable from one merely as inconsistent as I am.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix was a written rule, not a better prompt
&lt;/h3&gt;

&lt;p&gt;Tier 1 became a mechanical test: &lt;strong&gt;quote the particular.&lt;/strong&gt; A claim is Tier 1 only if you can point at a number, a named technology, a named place, an explicit timeframe, or a quantified policy. Seriousness of the work is not a criterion. "Expert work" and "works closely with the security team" name nothing checkable. Both are Tier 2.&lt;/p&gt;

&lt;p&gt;Then I enforced the rule in tooling. The labeler refuses a Tier 1 whose reasoning does not literally quote a substring of its own claim. Run against my drifted pass of 36 claims, the linter flags &lt;strong&gt;34 errors&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A blind 50-claim re-label after the rule:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Measure&lt;/th&gt;
&lt;th&gt;Pre-rule (n=36, one posting)&lt;/th&gt;
&lt;th&gt;Post-rule (n=50, 15 postings)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Exact tier&lt;/td&gt;
&lt;td&gt;75.0%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;86.0%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tier 1 boundary&lt;/td&gt;
&lt;td&gt;80.6%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;96.0%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The floor moved a lot after the rule. The samples are not matched — 36 claims from one posting under an earlier extraction, versus 50 claims across 15 postings from the current run — so do not treat +15.4pp as a causal estimate. Consistency improved. That is the claim. No model call was involved in producing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same model output, opposite conclusion
&lt;/h2&gt;

&lt;p&gt;With a floor I would actually use, the classifier numbers mean something:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Measure&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;95% CI&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Exact-tier accuracy&lt;/td&gt;
&lt;td&gt;76.7%&lt;/td&gt;
&lt;td&gt;69–83&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Tier 1 boundary accuracy&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;86.7%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;80–91&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tier 1 precision / recall&lt;/td&gt;
&lt;td&gt;0.902 / 0.754&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Against the pre-rule floor of 80.6%, the model looked six points better than the human. Against the re-measured floor of 96.0%, it sits nine points below.&lt;/p&gt;

&lt;p&gt;Same model output. Opposite conclusion. &lt;strong&gt;Which floor you pick decides the answer&lt;/strong&gt;, and most write-ups never publish a floor at all.&lt;/p&gt;

&lt;p&gt;Against my second-pass ceiling the model loses; against the labels I later recanted, part of the loss is me. In the blind post-rule pass I changed 7 tiers, and &lt;strong&gt;6 of those 7 landed on the model's answer&lt;/strong&gt; — without ever seeing the model's output.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tier 3 never fired
&lt;/h2&gt;

&lt;p&gt;Zero of 150 gold claims landed in Tier 3. The tier built for empty slogans, run on these 15 job ads, found nothing.&lt;/p&gt;

&lt;p&gt;Two causes, both upstream of the classifier:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Stage 1 drops slogans as atmosphere.&lt;/strong&gt; Mission and culture language never reaches the annotator at all. A Stage 1 error is invisible to a Stage 2 metric.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sharpening one boundary destabilised another.&lt;/strong&gt; Tier 1 got a mechanical test. Tier 3 kept a prose test. That is the &lt;code&gt;English&lt;/code&gt; row in the table above: a quotable token that is not a particular about the job.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The blind re-label makes it sharper. The same annotator, the same written rules, a day later, produced 5 Tier 3 labels in 50 claims. A 0% rate and a 10% rate are both consistent with the taxonomy as written. That is a worse problem than an unused tier, and I shipped it as a documented finding rather than patching it into looking good.&lt;/p&gt;

&lt;p&gt;The consequence: on this gold set the score is in practice &lt;code&gt;tier_1 / (tier_1 + tier_2)&lt;/code&gt;, inflated twice over. Comparisons between postings survive, because the bias runs the same direction for all of them. Absolute values must not be quoted as measurements of slogan content.&lt;/p&gt;

&lt;h2&gt;
  
  
  A slice that was not a sample
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;--limit 25&lt;/code&gt; plus &lt;code&gt;jobs[:limit]&lt;/code&gt; took an alphabetical slice, not a sample. Every posting pulled from one board had a title starting with "A". Every per-board statistic produced before I found it was invalid. Not an LLM problem; it would have changed a published number.&lt;/p&gt;

&lt;p&gt;Cost, from the run artifacts: the prompt is about 61% of all input tokens, 76% at Stage 2, so caching and batch size are the whole story — batching at 3.75 postings per call halved my first estimate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the score does not measure
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Not honesty.&lt;/strong&gt; A precise promise can be broken. A vague ad can conceal a good job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not quality.&lt;/strong&gt; Some excellent employers write badly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not intent.&lt;/strong&gt; Most filler is copied from a template by someone who did not choose it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not slogan content.&lt;/strong&gt; The 0.407 mean is an overestimate on two counts, both pushing the same way: missing Tier 3 in the denominator, and false-positive Tier 1s like &lt;code&gt;English&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The method's own limits, stated plainly. One annotator, so there is no inter-annotator agreement, only intra-annotator. Gold tiers sit on model-extracted spans, so an extraction error cannot be counted wrong. 150 claims, so roughly 7 points of confidence interval at 95% — an improvement of a few points is not a claim this set can support. The scope filter matches words rather than functions: I audited three borderline titles by hand, one was a genuine false positive, and the fix went into the rule instead of a hardcoded exclusion list.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell someone building the same thing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Label a slice twice, blind, before you write down any accuracy number. Your own self-agreement is the ceiling, and it is lower than you think.&lt;/li&gt;
&lt;li&gt;Log a warning when two metrics that should differ do not.&lt;/li&gt;
&lt;li&gt;Write the boundary rule as a mechanical test, then enforce it in code. The linter that refuses an unquoted Tier 1 was worth more than every prompt iteration combined.&lt;/li&gt;
&lt;li&gt;Publish the result that makes you look worse.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do you measure yourself twice before you report model agreement, or do you only check the model against the model?&lt;/p&gt;

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