<?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>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>
