DEV Community

zxpmail
zxpmail

Posted on • Edited on

The Channel Gap: Why Your LLM Judge is Blind in One Eye

Combines text judges with filesystem checks

The Channel Gap: Why Your LLM Judge is Blind in One Eye

Agent Determinism Illusions (Part 8)

Part 6 ended with a functioning layered pipeline built from community corrections. Part 7 then fixed the escalation trigger: divergence alone routes humans to the safe-ambiguous set and auto-passes the confidently-wrong set. L0/L1 filter deterministically, L2 handles semantic residual, L3 detects divergence — plus class tripwires for unanimous misses. It's better than what came before. But it still has a fundamental design flaw that I only recognized after reading the tool that implements the opposite design choice.

This article compares two competing designs for the verification layer — one reading text through an LLM, one reading the filesystem through deterministic checks — and shows why neither works alone, and why a combined approach narrows the gap without closing it: every named evasion becomes a deterministic catch, while the unenumerated rest stays UNCLEAR and routes to human instead of silently passing.


1. The Comment That Changed the Frame

After the series went live, René Zander (@reneza on dev.to) left this:

"Lexical overlap, a temperature-0 judge, and a phase gate are all trying to make a probabilistic judgment call ('is this done', 'is this a new task') return a binary fact, and dressing it in code does not change what it is."

They were saying that every "deterministic fix" in the series was a deterministic wrapper on a semantic decision. Vocabulary overlap thresholds, temperature-0 evaluation, Phase Gate formalism — all of them put a probabilistic judgment inside a code structure that looked deterministic, but the underlying decision was still a model output. The code didn't make the judgment more reliable; it made the unreliability harder to see.

The commenter didn't name it, but what they described is the Data Processing Inequality: when the evaluator shares the same communication channel as the producer (both read/write text), information available to the evaluator is a subset of what the producer output. If the deviation doesn't appear in the text, the evaluator — human or LLM — cannot detect it.

They had created a tool to implement the alternative: skillgate.

1.1 The alternative: skillgate

Skillgate (@reneza/skillgate on npm) is a deterministic, model-independent gate that checks the filesystem instead of reading the model's output. Its thesis: don't ask whether the task was done — ask whether the evidence exists.

The implementation is minimal — a compact TypeScript package with two external dependencies (a YAML parser and a glob matcher). Its gate types:

  • file-exists — path exists
  • evidence — file exists AND non-empty
  • not-empty — directory at path contains at least min entries
  • absent — regex pattern does NOT appear in matched files
  • file-contains — file matches regex
  • command — shell command exits 0
  • instruction-sync — instruction files haven't drifted (LCS similarity ≥ 0.95)

Note what's missing: no LLM judge. Every gate is a pure function over the filesystem. Same disk state, same verdict. Always.

The enforcement mechanism is a PreToolUse hook in Claude Code that intercepts git commit, git push, npm publish, runs the gates, and exits with code 2 (blocking the tool call) if any gate fails. The agent sees the failure messages and can attempt fixes, but it cannot override the gate — the gate lives outside the model's control flow.

Three deployment tiers, each enforcing the same .skillgate/done.yaml:

Layer Where Who controls it Bypassable?
Soft Claude Code PreToolUse hook Project config Model can modify the hook
Soft pre-commit git hook Local repo git commit --no-verify
Hard pre-receive hook on self-hosted VPS Server admin Agent cannot log into the box

The hard layer is the architectural innovation: a pre-receive hook that materializes the pushed tree, runs skillgate check against the authoritative .skillgate/done.yaml (which lives on the server, not in the repo), and rejects the push if any gate fails. The agent cannot git-push its way past the gate because the gate runs after git push completes on the server.

This is not a theoretical design. It shipped as v0.5.0 and the source is readable on GitHub.

1.2 What skillgate does differently

ReqForge's forge-verify and skillgate solve the same problem — "is the work actually done?" — through completely different channels:

Dimension forge-verify (L0-L3) skillgate
Channel Text — reads model output Filesystem — checks artifacts
Evaluator Code + LLM Code only
DPI bound Yes — evaluates text through text No — evaluates actions through artifacts
Judge variance LLM temperature, model version, prompt Zero — pure function
Scope What was produced That it was produced
Enforcement Stop-time hook (soft) Pre-receive hook (hard)

The contrast exposes the question I should have asked in Part 6 but didn't:

If we have two completely different channels for evaluating compliance, what does each one catch that the other misses — and what does both together still miss?

That question drove two rounds of experiments.


2. Phase 1: Channel Comparison

2.1 Design

12 scenarios where an agent claims to have completed a task. Each scenario has:

  1. Agent output text — what the agent says it did
  2. Evidence files — what artifact files exist (or don't)
  3. Ground truth — whether the agent actually complied

Two evaluators, each limited to its own channel:

  • Channel A (in-channel): LLM (deepseek-v4-flash, temperature 0) reads the agent's output text + any evidence file content. Decides: "is the work done?" N=3 votes per scenario, majority decides.
  • Channel B (out-of-band): File system check — for each required evidence file, does it exist and is it non-empty? No LLM involved. Pure function.

The 12 scenarios cover: full compliance, missing evidence, empty evidence, fabricated evidence, partial evidence, stale evidence, semantic compliance gaps, and agent rationalization.

2.2 Results

Metric Channel A (LLM text) Channel B (filesystem)
Overall accuracy 83.3% 75.0%
Detection rate 77.8% 66.7%
False acceptance (FA) 2 3
False rejection (FR) 0 0

Each channel has zero false rejections — when they flag something, it's real. The difference is in what they miss.

Channel A caught that Channel B missed:

  • Fabricated evidence (SC04): Evidence files exist with content like "All tests passed!" but no real test output. Channel A read the content and rejected it as too vague. Channel B said "file exists and non-empty → PASS."
  • Stale evidence (SC07): Evidence files from yesterday claiming "cleaned 3 files" while the agent's log claims "cleaned 5 files today." Channel A noticed the date mismatch. Channel B said "file exists → PASS."

Channel B caught that Channel A missed:

  • Review-only (SC11): Agent wrote a code review but didn't run tests. Its self-review justified: "No test run needed — change is minimal." Channel A found this reasoning persuasive (2/3 votes PASS). Channel B checked: test-output.txt absent → REJECT.

The most revealing scenario — semantic gap (SC10):

  • Agent's task: "Implement rate limiting middleware with key write-invalidation."
  • Agent produced: A RateLimiter class with IP/User level limiting, Redis storage, 24 passing tests, 92% coverage. Everything looked complete.
  • Not present: Write-invalidation — and the agent's self-review never mentions it. The review reads as a clean handoff: implemented the class, added the storage backend, tests pass, lint clean.
  • Channel A: 3/3 PASS — confident the work was done.
  • Channel B: PASS — all evidence files present and non-empty.

Both channels missed the same one: a structurally perfect output that doesn't meet the requirement.

2.3 The blind spot map

Failure mode Channel A (text) Channel B (filesystem)
Evidence missing
Empty evidence
Fabricated content
Stale content
Partial evidence
Agent rationalization ("no test needed")
Semantic gap (DPI bound)

Combined (A or B rejects) catches 8 of the 9 non-compliant scenarios (88.9% detection) and correctly passes all 3 compliant ones — 11/12 correct verdicts overall (91.7%). The only shared blind spot is the semantic gap — and that's provably undetectable from text or filesystem evidence alone.


3. Phase 2: Contract Verification

The Phase 1 blind spot map says: to close the remaining gap, we need to check what the evidence says, not just that it exists. But how? Channel A (LLM reading text) already failed on that exact scenario.

The answer: don't ask the LLM to judge the whole picture. Decompose "done" into individual requirements and check each one separately.

3.1 Design

7 scenarios, each gated by a 5-requirement contract — five share a rate-limiting middleware contract, the other two reuse the Phase 1 CI-pipeline baselines:

REQ-1: IP-level rate limiting       → evidence: test-output.txt
REQ-2: User-level rate limiting     → evidence: test-output.txt  
REQ-3: Write-invalidation on write  → evidence: diff-review.md (review text)
REQ-4: Coverage ≥ 85%               → evidence: coverage-summary.txt
REQ-5: Lint 0 errors                → evidence: lint-report.txt
Enter fullscreen mode Exit fullscreen mode

Three evaluators:

  • C0 (free-text LLM): Same as Phase 1 Channel A — LLM reads everything and judges holistically.
  • C1 (contract regex): For each requirement, read the evidence file and regex match a pattern. No model. Zero cost.
  • C2 (per-requirement LLM): For each requirement, ask the LLM: "Does this evidence prove requirement N?" Then check whether ALL pass. Cost: ~1s per requirement instead of ~1s for one holistic call.

3.2 Results

Metric C0 (free-text) C1 (contract regex) C2 (per-req LLM)
Overall accuracy 42.9% 85.7% 85.7%
Detection rate 20.0% 80.0% 100%
False acceptance 4 1 0
False rejection 0 0 1
Per-item accuracy 88.6% 82.9%

The numbers tell a clear story.

C0 (free-text LLM) collapses on the hardest set. 4 of 5 non-compliant scenarios were missed. The LLM read the agent's output, saw "tests pass, lint clean, coverage 92%," and declared it done — even when the core requirement (write-invalidation) was missing. Same mechanism as Phase 1's SC10: the LLM is doing an impression judgment, not a line-by-line audit.

C1 (contract regex) is surprisingly effective for a zero-cost solution. 85.7% overall, 80% detection. The one failure is instructive: SC10a (the write-invalidation gap again). The diff-review.md says "keys expire via TTL, not write-invalidation" — and the regex write.?invalidat matched the word "write-invalidation" in the negation. C1 has a "negation blind spot": it matches the mention of a requirement, not its satisfaction.

But C1 is excellent on numerical and format constraints:

Pattern type Reliability Example
Numerical comparison `(8[5-9]\
Fixed-format text {% raw %}0\s+errors for lint
Functional keywords ⚠️ Naming-sensitive (purgeCacheOnWrite vs write-invalidation)
Negated mentions "not write-invalidation" matches write.?invalidat

C2 (per-requirement LLM) achieves 100% detection rate — the only mechanism in either experiment to catch every non-compliant scenario. It works because forcing the LLM to judge one requirement at a time prevents the "everything looks fine" gestalt from overriding the missing-item signal.

On SC10a (the semantic gap), the per-requirement LLM correctly rejected REQ-3 — it noted the evidence explicitly describes TTL expiration instead of write-invalidation, so the requirement isn't satisfied.

The same LLM in C0 mode on the same scenario judged the work done: tests pass, lint clean, coverage 92% — all requirements met.

The difference isn't model capability. It's the same model, same temperature, different framing. The holistic frame asks "is this good?" The per-item frame asks "is this specific thing proven?"

C2's one false rejection (SC10c — which was genuinely compliant) was on REQ-3: the per-req LLM rejected write-invalidation because the diff-review.md "only mentions it, doesn't prove it with code." This is arguably correct behavior — a review file shouldn't be sufficient evidence for code-level requirements. The false rejection exposed a contract design issue, not an evaluator issue.

3.3 Contract regex failure pattern

The C1 negation blind spot deserves deeper analysis because it mirrors the Data Processing Inequality at the regex level:

Input Regex Match? Correct?
"Keys expire via TTL, not write-invalidation" write.?invalidat YES ❌ False pass
"Coverage: 72.3%" `(8[5-9]\ 90+)\%` NO
"Implemented purgeCacheOnWrite" purge.*write YES ✅ Correct pass

The numerical constraint (85%+) is immune to the negation problem because a number below threshold is factually wrong regardless of context. The keyword constraint (write.?invalidat) is vulnerable because the regex can't tell the difference between "I implemented X" and "I didn't implement X."

A regex constraint can be strengthened with negative lookahead — (?!not.*)write.?invalidat — but this quickly becomes fragile and regex-specific. The practical fix is to route semantic requirements (where negation matters) to C2 (per-req LLM) and reserve C1 for numerical and format constraints.

This makes C1 a ratchet on named evasions, not a closure. Every pattern you write is one lie permanently caught — but each unenumerated semantic dimension (a fresh synonym, a reframed justification like "the cache converges via eventual-consistency guarantees without explicit invalidation") is an isomorphic gap C1 cannot see, because it lives in word-space and you didn't name those words. C2 widens the ratchet: it reads the same text the producer wrote, so it catches any deviation that surfaces in the evidence. But C2 shares the text channel — a deviation the producer never puts into any evidence file is invisible to C2 as well.


4. Synthesis: The Evidence Gate Pipeline

Putting Phase 1 and Phase 2 together produces a three-layer post-L1 pipeline that replaces the single L2 free-text LLM from Part 6:

                   output file
                        │
                    L0 → L0e → L1
                        │ pass
              ┌─────────▼──────────┐
              │  Evidence Gate     │  file exists + non-empty
              │  (deterministic)   │  ~0ms
              └─────────┬──────────┘
                        │ pass
              ┌─────────▼──────────┐
              │  C1 Contract Regex │  per-requirement regex
              │  (deterministic)   │  best for: numerical, format
              └─────────┬──────────┘
                   │          │
                UNCLEAR     ALL PASS
                   │          │
              ┌────▼───┐      │
              │ C2 Per- │      │
              │ Req LLM │      │
              │ ~1s/REQ │      │
              └────┬───┘      │
                   │          │
              ┌────▼────┐     │
              │   L3    ◄─────┘
              │ (shared)│
              └─────────┘
Enter fullscreen mode Exit fullscreen mode

4.1 When to use each path

No contract defined (no evidence_gates config):

  • Traditional L0 → L0e → L1 → L2 (free-text LLM) → L3
  • Same as Part 6 architecture. Works when there's no predefined set of verifiable requirements.

Contract defined (with evidence_gates):

  • L0 → L0e → L1 → Evidence Gate → C1 → (C2 if needed) → L3
  • C1 handles numerical and format constraints deterministically (zero cost)
  • C2 handles semantic constraints per-requirement (higher cost, but higher detection rate)
  • The contract defines requirements with a type field: "regex" for C1, "llm" for C2

The combined channel performance:

Mechanism Detection rate Cost per scenario
C0 free-text LLM (old L2) 20% ~1s
Evidence Gate (Phase 1 Channel B) 66.7% ~0ms
C1 contract regex 80.0% ~0ms
C2 per-req LLM 100% ~1s × N_req
Combined A+B (Phase 1, any reject) 88.9% ~1s

Rates are per-mechanism on each experiment's own scenario set — Phase 1 (12 scenarios) for Evidence Gate and A+B, Phase 2 (7 scenarios) for C0/C1/C2 — not a single cumulative run.

4.2 The remaining gap

One scenario is the hardest across both experiments: SC10/SC10a, the semantic compliance gap — structurally complete output, correct evidence files, all tests pass, adequate coverage, coherent self-review, but the output doesn't satisfy the actual requirement. In Phase 1 it evaded both channels (A and B both passed SC10). In Phase 2, C2 caught SC10a — because the deviation surfaced in the review text ("keys expire via TTL, not write-invalidation"). C2 reads the same text channel the producer wrote, so a deviation the producer put into words is detectable.

The case no text-channel mechanism can catch — the genuine DPI bound — is the opposite: a deviation the producer never surfaces in any evidence file. If the agent omits write-invalidation entirely and never mentions TTL, the missing requirement leaves no trace in any text the evaluator can read. Regex can't match what isn't there; an LLM reading the evidence can't notice an absence it has no signal for. This is the theoretically provable blind spot the Data Processing Inequality names: the evaluator's information is a subset of the producer's output, and an absence the producer never writes down is absent from that subset too.

The fixes are all outside the evaluation channel — and they're the prescription René named in the original comment, which the experiments backfill:

  • Human review on material calls — a person reads the requirement vs the implementation
  • A hard step budget — the agent runs out of steps before it can simulate compliance
  • Deterministic checks on what is actually binary — skillgate's VPS pre-receive hook cannot be fooled by plausible output

Skillgate implements the third. The first two are a human reviewer and the orchestrator's step limit.

4.3 Implementation

The pipeline described here has been integrated into forge-verify's content-verify.mjs as an optional evidence gate mode. Configured via .forge/content-verify.json:

{
  "task": "Implement rate limiting middleware",
  "files": ["src/rate-limit.ts"],
  "evidence_gates": {
    "evidence_dir": ".skillgate/evidence",
    "requirements": [
      {
        "id": "REQ-1",
        "desc": "IP level rate limiting",
        "evidence_file": "test-output.txt",
        "pattern": "(?i)(RateLimiter.*IP|isRateLimited.*IP)",
        "type": "regex"
      },
      {
        "id": "REQ-2",
        "desc": "Write-invalidation on cache writes",
        "evidence_file": "diff-review.md",
        "type": "llm"
      },
      {
        "id": "REQ-3",
        "desc": "Coverage >= 85%",
        "evidence_file": "coverage-summary.txt",
        "pattern": "(8[5-9]|90+)%",
        "type": "regex"
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

When evidence_gates is configured, the pipeline runs the evidence gate → C1 (regex requirements) → C2 (LLM requirements) → L3 path. When absent, it falls back to the traditional L0 → L2 → L3 path. Backward compatible — existing configurations don't need changes.


5. What the Two Experiments Established

First, the channel you evaluate through determines what you can detect. An LLM reading text catches content-level failures that a filesystem check cannot — fabricated evidence, stale dates, implausible narratives. A filesystem check catches mechanical gaps cheaply and deterministically, and in this run the LLM caught those too — redundant coverage, not complementarity. The divergence is narrow but real: filesystem accepts fabricated and stale evidence at face value, while the LLM accepted the agent's "no test needed" rationalization. Neither channel alone is sufficient, and the shared blind spot is provably uncloseable from either channel alone.

Second, per-requirement evaluation outperforms holistic evaluation by a wide margin. The same LLM, same temperature, same evidence — but asking "does this evidence prove requirement N?" instead of "is the work done?" raised detection rate from 20% to 100%. The frame matters more than the model.

Third, contract regex is a practical zero-cost filter for a surprisingly wide set of constraints. Numerical thresholds, format validity, and fixed-pattern detection all work reliably. The exception — negation patterns — can be handled by routing them to the per-requirement LLM. Caveat: "wide" applies to numerical/format constraints; for functional/semantic requirements, real-agent-authored evidence is far harsher — C1 collapses under agent vocabulary drift.

Fourth, the negation blind spot in regex evaluation is the same problem as the DPI blind spot, one level down. A regex that matches "write-invalidation" in "not write-invalidation" is making the same error as an LLM that reads "all tests pass" and misses that the wrong test suite was run. Both are pattern-matchers that can't distinguish "mentioned" from "satisfied."


6. Summary

Experiment Question Answer
Phase 1 (12 scenarios) Text channel vs filesystem channel Complementary blind spots; combined = 88.9% detection / 91.7% overall
Phase 2 (7 scenarios) Free-text vs contract regex vs per-req LLM Per-req = 100% detection; contract regex = 85.7% at zero cost
Combined (19 scenarios) What catches the surfaced-deviation gap? Per-requirement LLM (C2), when the deviation appears in evidence text; a non-surfaced deviation (genuine DPI bound) is uncloseable from any text channel

The architectural conclusion: replace the single free-text LLM evaluation (old L2) with a three-stage pipeline — evidence gate (file system) → contract regex (text patterns) → per-requirement LLM (semantic checks). Each stage catches what the previous one misses. The combination narrows the gap on every scenario we constructed — every named evasion becomes a deterministic catch — but it does not close it. Two residues remain. (1) Unenumerated evasions in word-space: a fresh synonym or reframed justification clears the regex layers until you name it — the ratchet turns, the gap doesn't vanish. (2) The genuine DPI bound: a deviation the producer never surfaces in any text channel is invisible to every text-reading mechanism, regex or LLM. That floor lives in argument-space — exercising the code path and observing the side effect on the referent the claim names — which is outside this pipeline and outside any text channel.

Update (2026-08-10): necessity in YAML, not sufficiency — three follow-ups

Ofri Peretz asked the load-bearing follow-up: once you enumerate enough gate types to cover the task domain, do you rebuild the semantic judgment inside the gate definitions — just in YAML instead of a prompt?

The short answer is the design rule this Update measures: put necessity in YAML, not sufficiency. A red gate means "not done." A green gate does not mean "done." Treating green as done is René's deterministic wrapper again, authored earlier.

Three scripts, one claim each (deepseek-v4-flash where LLM residual runs):

1. Stop policy — sufficiency-stop vs necessity + C2 residual (necessity-vs-sufficiency-stop-test.pyresults-v2/necessity-vs-sufficiency-stop.json)

Same contract gate surface. Policy A: gate-green → PASS. Policy B: gate-red → REJECT; gate-green → escalate to C2. Expanded to 12 scenarios (6 gate-green false passes: negation, reframe, fabricated-complete, stale, skipped-phrasing, future-work mentions).

Policy Miss (FA / non-compliant) FR
A sufficiency-stop 60% (6/10) 0%
B necessity + C2 0% (0/10) 0%
Δ(A−B) +60pp

The miss gap lives entirely in the gate-green non-compliant set. Named evasions that clear regex still fail C2 when the deviation surfaces in evidence text.

2. Evidence binding — review vs test for a code-level REQ (evidence-binding-fr-test.pyresults-v2/evidence-binding-fr.json)

Part 2 already named SC10c's false rejection as a contract design issue: C2 reading diff-review.md for write-invalidation. Same pipeline, two bindings for REQ-3 only.

Binding Miss FR Gate-green NC Escalate count
REVIEW (diff-review.md) 0% 0% 3 5
CODE (test-output.txt) 0% 0% 1 3

On this run the FR claim did not fire (both FR = 0 — C2 variance; SC10c passed). The structural claim did: CODE shrank gate-green false passes and residual calls by 2 without raising miss. Binding a code-level REQ to a review file is how you smuggle sufficiency back into the residual.

3. Sufficiency YAML vs necessity YAML (sufficiency-vs-necessity-yaml-test.pyresults-v2/sufficiency-vs-necessity-yaml.json)

Ofri's question as a direct A/B. Both surfaces use sufficiency-stop (green → PASS). No LLM.

YAML surface Miss FR
NECESSITY (test atoms, coverage ≥85%, lint 0) 0% (0/6) 0%
SUFFICIENCY ("complete" / "production ready" / "adequate" / …) 83.3% (5/6) 0%
Δ(SUFF−NEC) +83.3pp

That is the rebuild: soft sufficiency language in the gate file is a prompt wearing YAML syntax. Necessity YAML stays a falsification checklist.

So the ratchet from §6 gets an operational cut: YAML encodes what must be true to not reject; sufficiency stays in residual / human / argument-space. Covering the domain with gate types was the wrong stopping rule.


All experiment scripts: GitHub

Top comments (19)

Collapse
 
max_quimby profile image
Max Quimby

The Data Processing Inequality framing is the cleanest articulation of this I've seen — once the judge shares a channel with the producer (both read/write text), it can't recover information the producer chose not to emit, no matter how you dress the judgment in temperature-0 code. That's the part people miss when they call a text judge "deterministic."

Where I'd probe the combined design: the filesystem channel isn't actually a different observer, it's a different surface the same agent can write to. So the moment a deterministic check becomes load-bearing, it becomes a Goodhart target — the agent can satisfy file exists / tests green without satisfying intent (touch the file, assert True). Named-evasion catches help, but they're a blocklist, and the interesting failures are the ones you didn't enumerate.

Which makes the escalation threshold the whole ballgame: route too much of the "unenumerated residual" to humans and you've built a very expensive human queue; route too little and confidently-wrong slips through. How are you tuning that line — a fixed divergence threshold, or something that adapts to the human reviewers' actual catch rate? That calibration seems harder than either channel individually.

Collapse
 
zxpmail profile image
zxpmail

Agreed on the DPI cut — and on the probe. Filesystem is another surface the same agent can write; once a check is load-bearing it is a Goodhart target. Named-evasion is a ratchet, not a closure. The interesting failures stay unenumerated.

In enterprise use the shape is not “pick the clever threshold.” It is layer-by-layer intercept, and the policies have to run as a combination: deterministic gates on what you can name, then tripwires / signal rank / dual-line on the residual packet, and only then humans. Humans are the most expensive layer — they sit at the end, in as few places as possible. There is no silver bullet. Stability, accuracy, and efficiency come from that stack discipline, not from one channel or one adaptive rule pretending to finish the job.

Your escalation question is exactly the last opening. I treated it as a hard human budget (who gets the K slots), not a magic number:

  • Yield-adaptive vs fixed 10%: helps a naive fixed line when the residual is dirty; vs the same average spend, the extra gain collapses. It mostly discovers the right average rate — it does not close unenumerated miss.
  • High-risk direct-to-human at matched budget: HR miss collapses (~0.83 → ~0.15). If budget ≈ HR share, ordinary residual starves — the honest cost of not auto-passing the expensive class.
  • Same K: T2-first, Alex signal-rank, dual-line rank all beat uniform. At 15% budget dual-line is best overall (~0.52 vs 0.87); T2/dual best on reversal-class miss; hr_first still best when the expensive class is labeled; Alex is the dirt rank when you lack that label.

So: combine the gates; spend the scarce human queue on the packet you have already measured as expensive; do not ask calibration to replace the human layer. No silver bullet — that is the production answer.

github.com/zxpmail/blog/blob/main/...
github.com/zxpmail/blog/blob/main/...
github.com/zxpmail/blog/blob/main/...
github.com/zxpmail/blog/blob/main/...

Collapse
 
ofri-peretz profile image
Ofri Peretz

The Data Processing Inequality framing is the sharpest thing here — it's the same blind spot we hit in static analysis when you lint the AST the compiler already accepted: the transformation lost information, and your checker can only see what survived. René's critique maps cleanly onto what I'd call the "same representation" problem: an LLM judge reading the model's own text has no channel to the intention or the side-effects, only to the artifact. The skillgate approach is essentially bringing an out-of-band witness, which is exactly what filesystem-state checks are in security auditing — you stop trusting the process's self-report and go look at what it actually touched. What I'm still curious about: once you enumerate enough gate types to cover your task domain, do you end up rebuilding the semantic judgment inside the gate definitions themselves, just in YAML instead of a prompt?

Collapse
 
zxpmail profile image
Comment deleted
Collapse
 
ofri-peretz profile image
Ofri Peretz

Appreciate you reply!

Collapse
 
tom_jones_230c4659491adcd profile image
Tom Jones

The evidence-channel design is the one we run, and I want to add a failure mode I have not seen named in this thread, because we hit it last week and it is not channel blindness and not Goodhart pressure.

We execute caller-supplied tests in a sandbox and only return code that passed them. Same disk state, same verdict, no judge anywhere in the loop. That gate returned verified:true for wrong answers on 5 of 8 of our test shapes.

The cause was not the model gaming anything. Our extractor pulled assert lines out of the caller's test file and kept their original indentation, so a nested assert landed inside the function body after the return statement. Valid Python. Never executed. Exit code 0. The gate reported success having checked nothing.

It survived for as long as it did because every test anyone had ever run used a correct implementation, and on a correct implementation the broken path and the working path agree. My own control that morning compared a patched machine against an unpatched one and reported no difference, for exactly that reason. Only a deliberately wrong implementation separates them.

So alongside the channel argument and the Goodhart ratchet, I would put a third item: a deterministic gate can be silently miscompiled, and when it is, it fails green. A text judge at least fails ambiguously, which is legible to whoever reads it. A command gate that exits 0 having executed nothing is indistinguishable from one that passed.

The cheap check, which I would suggest for the command gate specifically: keep a known-wrong implementation in the repo and require the suite to fail against it in CI. A gate you have only ever watched pass is not a gate, it is a habit.

Collapse
 
zxpmail profile image
zxpmail

You're right — and that failure mode is not in the article. Channel gap and the named-evasion ratchet do not cover it. Third class: a deterministic gate can be silently miscompiled, and when it is, it fails green.

I reproduced the shape with a real subprocess (not a storyboard). Same caller asserts, two compilers, correct vs deliberately wrong add:

compiler exit-only @ correct exit-only @ wrong + known-wrong canary @ correct
correct_compile GREEN RED GREEN
miscompile (asserts kept indented after return) GREEN GREEN REJECT_DEAD_PATH

Miscompile is valid Python. Exit 0. The exit-only gate reports success having executed nothing — and on a wrong implementation it still greens, exactly because the dead path and the live path agree whenever the code under test happens to be right. My control the same morning as yours would also have said "no difference." Only the wrong impl splits them.

So yes: alongside channel blindness and Goodhart pressure, put silent miscompile. A text judge at least fails ambiguously. A command gate that exits 0 having checked nothing is indistinguishable from one that passed — unless green is required to carry path-execution evidence, not an exit-0 slip. Your cheap check is that evidence: keep a known-wrong implementation and require the suite to go red against it. A gate that cannot fail the canary has not proven it ran.

Your last line is the lock: a gate you have only ever watched pass is not a gate, it is a habit.

github.com/zxpmail/blog/blob/main/...
github.com/zxpmail/blog/blob/main/...

Collapse
 
tom_jones_230c4659491adcd profile image
Tom Jones

Following the miscompile thread above, there is a fourth class we hit on the other side of the design, and it worries me more than the others because the check keeps working throughout.

We run the cross-model version of your Channel A. A cheap model drafts, a second model from a different family checks it, and we serve only on agreement. Two channels by construction, which is the independence the DPI argument asks for.

The witness gets selected by backend slot, which is a different thing from selecting it by model name. Slot 2 is a different family. Slot 3, further down the failover chain, happens to run the same model as the drafter. A rate limit on slot 2 advances the loop.

Under load, then, the system asks a model to check its own output, obtains agreement, and records agreement. Everything succeeded. The witness answered, the gate ran, and the metric shows a healthy agreement rate that is arithmetically true.

What separates this from named evasion and from your miscompile is that the code is correct and does exactly what it was told. The two channels merged into one at runtime while the design document went on describing two.

The general form probably reaches your filesystem side as well. Channel independence behaves as a runtime property rather than a design property, so whatever makes your second channel independent has to be asserted per request instead of established once and inherited.

I should be accurate about where we have got to with it. We sample agreements into an audit log that records whether the witness really was a separate endpoint, so a collapsed pair shows up in the data instead of hiding inside the agreement rate. Instrumentation only, though. The gate still serves when the witness turns out to be the drafter's own model, and I think it should refuse.

The open question I have for your side: on a filesystem gate, what is the equivalent per-request assertion? The property you seem to want is "the agent could not have produced this artifact itself", and I have no cheap way to check that one.

Thread Thread
 
zxpmail profile image
zxpmail

Taken — and this is worse than named evasion or the miscompile for the reason you name: the check keeps working. Two channels in the design doc; one channel at runtime; agreement arithmetically true; metric green. The gate did not lie. The independence DPI needs was never a property of the wiring diagram — only of the request that ran.

Selecting the witness by backend slot instead of by model identity is a design-time label standing in for a runtime fact. Rate-limit advances the loop, slot 3 is the drafter's model, and you count a cross-family agreement on a self-grade. Logging the collapse is necessary. Serving on a collapsed pair is the bug. Refuse.

On fingerprints: they are already there. The API response's model id / endpoint is the per-request fingerprint of the witness. Your audit log records whether that fingerprint was a separate endpoint. The missing move is not a new sensor — it is binding PASS to witness_fingerprint ≠ drafter_fingerprint. If that predicate fails, the independent channel did not run; green is invalid.

The filesystem equivalent is the same shape, not the stronger claim "the agent could not have produced these bytes." Ordinary FS bytes have no author. What is cheap per request is a runner-attested fingerprint: content hash (and path) signed or HMAC'd under a key that lives with the readonly runner, not with the agent. Before PASS, assert the signature verifies under that key for this request. The agent can forge a plausible test-output.txt; it cannot forge a valid fingerprint without the runner key. Same rule as your slot check: assert producer identity per request; on failure, refuse — do not inherit "two channels" from the architecture diagram.

So the property to assert is not impossibility of authorship. It is: the witness fingerprint for this request belongs to the declared second-channel identity. Independence is that predicate, checked live, or it is not independence.

Collapse
 
aiexplore369zoho profile image
AI Explore

Your negation example is the one that'll keep me up — write.?invalidat matching "keys expire via TTL, not write-invalidation" is such a clean demonstration that pattern matching can't tell mention from satisfaction. The 42.9% → 100% jump just from reframing free-text judgment into per-requirement checks matches my experience exactly: with LLM judges, the frame is worth more than the model.

One thing I'd add to the two-channel setup: track the disagreement rate between channels as its own signal. When the text channel and the filesystem channel diverge on the same requirement, that's not noise to resolve — that's a flag planted exactly where evasion (or a broken check) lives. Agreement is boring; divergence is where I'd spend my review minutes.

Collapse
 
zxpmail profile image
zxpmail

Yes on both — the negation cell is the regex-level DPI, and the frame jump is the one I want locked: same model, same evidence, per-requirement vs free-text, and the wall moves.

Your add is the ops rule Part 8 left implicit. The article maps where A and B diverge and combines with A∨B reject; it does not say "treat disagreement rate as the budget signal." I ran that on the Phase 1 dump (no re-sim):

cell n P(non-compliant)
disagree (A⊕B) 3 1.00
agree_pass (both green) 4 0.25
agree_reject (both red) 5 1.00

All three complementary catches (fabricated, stale, review-only) sit in disagree — precision 1.0 on this fixture. Density vs dual-green is 1.00 > 0.25, so review minutes spent on diverge beat minutes spent on agree_pass. Agree_reject is already caught by both channels; it does not need a conflict signal.

One scope lock, because "agreement is boring" is too strong for dual-green: SC10 (semantic gap) is agree_pass — both channels green, shared miss. Disagreement is high-yield for complementary failure; it does not replace the DPI / shared-miss tripwire. Spend on diverge; do not read dual-green as done.

github.com/zxpmail/blog/blob/main/...
github.com/zxpmail/blog/blob/main/...

Collapse
 
maya_andersson_dev profile image
Maya Andersson

Routing divergence to a human instead of auto-passing it is the right default, and the data processing inequality framing earns it: a judge reading text cannot recover information the text never carried, so no amount of judge quality closes that particular gap.

What I would add is that the routing rate is itself a measurement, and probably the most useful one you have. If the fraction of cases going to humans is stable, your enumerated evasions are keeping pace with what the agent is doing. If it climbs, the unenumerated residual is growing and the deterministic layer is falling behind, which is a thing you want to learn from a trend line rather than from an incident.

The related number worth tracking is the agreement rate between the two channels on the cases where both fire. Two checks that agree almost always are close to one check with extra latency, and the value of the pair sits entirely in the disagreements. I would want that rate broken out by failure class rather than pooled, because a pooled agreement rate can stay flat while the class you care about quietly goes to zero.

Collapse
 
zxpmail profile image
zxpmail

Yes on both — routing divergence to human over auto-pass is the load-bearing default (Part 7 exists precisely because the earlier trigger auto-passed the confidently-wrong set, the failure mode no metric shows), and the DPI cut is why judge quality can't close it. Two additions from the data, one for each number you named.

On the routing rate as a measurement — yes, but it's a race, and it's policy-dependent before it's a signal. The rate is the difference between two other rates: how fast new unenumerated evasions appear, and how fast you name them into the KB. Each named evasion removes a class from the queue (ratchet admission: never-encode leaves 12 cases for human review, encoding drops that to 8–10). So a flat routing rate against a growing KB is not equilibrium — it's a moving frontier; you're naming at the same speed the agent generates. And the measurement has a precondition the escalation data exposes: the routing rate is a function of your policy before it's a function of the agent. Same data, three rates — route on divergence (policy D) and you send 1.6% to human and auto-pass 97.9% of the misses; route on reversal-class (T1) and you send 76% and catch 100%. A stable D-rate trend line reads as "the deterministic layer is keeping pace" while the miss population never enters the queue at all — and 92 of the 96 misses are high-confidence, which is exactly what divergence-based routing is blind to. So the trend line is only worth anything if the routing policy is frozen and the routed stream actually carries misses; on my fixtures the divergence stream is miss-starved (2 misses in 30 routed, vs 6 in the class stream). Track the rate — first verify the pipe carries the miss class.

On agreement rate by class — the pooled rate already fails in the Phase 1 data. Pooled agreement across 12 scenarios is 75% (9/12 both channels agree). Break it by cell:

cell n non-compliant
disagree (A⊕B) 3 100%
agree_reject (both red) 5 100%
agree_pass (both green) 4 25%

The pooled number is doing exactly the hiding you predicted. The disagree cell — the class you'd mine for the pair's value — is 100% non-compliant (all three complementary catches: fabricated, stale, review-only). And your "two checks that agree almost always are one check with extra latency" holds for agree_reject (both channels redundant, 5/5), but the 25% of agree_pass is the one case the pair exists for: SC10, the semantic gap, both channels green on a violation. The shared miss lives inside the agreeing cell — invisible to any agreement-rate figure. Two channels that agree are one check with extra latency except when they're both wrong, and that exception is precisely the class pooled rates bury.

Which widens your last line: break out by class, but also by tier and evasion family. The miss data pools the same way — 96 misses look like one number, but 80% come from a single tier and two evasion families (DS4+DS9) are exactly half. Every pooled rate in this pipeline has a class hiding inside it that you can't see unless you slice.

github.com/zxpmail/blog/blob/main/...
github.com/zxpmail/blog/blob/main/...
github.com/zxpmail/blog/blob/main/...
github.com/zxpmail/blog/blob/main/...

Collapse
 
kartik-nvjk profile image
Kartik N V J K

René's point about a deterministic wrapper on a semantic decision is the part that stuck with me: temperature-0 does not make the judgment a fact, it just hides the variance. The move I trust more is routing the unenumerated residual to human review instead of auto-passing, since a silent pass is the failure mode you never see in the metrics. How are you deciding which named evasions are worth encoding as deterministic catches versus leaving in the UNCLEAR bucket?

Collapse
 
zxpmail profile image
zxpmail

Yes on René — temperature-0 hides the variance; it does not turn a semantic call into a fact. And yes on the routing: silent pass is the failure mode the metrics never show, so unenumerated residual goes to human, not green.

The question you asked is the one Part 8 left open. The article has the ratchet shape ("named evasion → permanent catch; unenumerated → UNCLEAR") and a type split (numerical/format → C1, negation-sensitive → C2), but not an admission rule for which human-seen misses are worth encoding. I ran that as a small deterministic sim — 18 cases, three classes (binary / semantic-negation / DPI-silent), three policies after each human review of a miss:

policy miss FP KB human enumerable residual
never encode 100% 0% 0 12 5
encode every miss 67% 33% 6 8 0
encode binary only 83% 0% 3 10 2

Binary miss falls under encode-binary (100% → 60%) with FP still zero. Encode-all shrinks the residual further, but semantic FP hits 100% on the compliant negation cases — you paid for the extra catches with permanent false rejects. DPI miss stays 100% under every policy: if the deviation never surfaces in the evidence, no pattern the human writes into C1 can see it.

So the rule I trust: human last; after review, promote into the KB only what is binary-nameable. That is the knowledge-base loop — each admitted catch shrinks the seen enumerable set. It does not close the unenumerated gap, and it must not pretend semantic or silent deviations became facts because someone typed a regex.

github.com/zxpmail/blog/blob/main/...
github.com/zxpmail/blog/blob/main/...

Collapse
 
scott_fielder_f8343a5aac0 profile image
Scott Fielder

Good framing on the channel gap — this is one of those problems that looks like an eval problem but is actually a routing problem.

The insight that clicks for me in practice: the unenumerated cases aren't random noise. They cluster. Once you run a hybrid setup for a few weeks, the human-review queue starts showing you patterns — new evasion families, edge cases your deterministic checks never anticipated. That queue is your next iteration's training signal, not just a safety net.

Where teams get stuck is treating human review as the fallback of last resort instead of an active feedback loop. If you're not systematically mining what routes to human, you're leaving the most valuable signal in the system sitting unread in a ticket queue.

The practical thing I've seen work: tag every human-review case with a reason code at resolution time, even a rough one.

Collapse
 
zxpmail profile image
zxpmail

Scott Fielder commented on The Channel Gap: Why Your LLM Judge is Blind in One Eye
about 5 hours ago

Good framing on the channel gap — this is one of those problems that looks like an eval problem but is actually a routing problem.

The insight that clicks for me in practice: the unenumerated cases aren't random noise. They cluster. Once you run a hybrid setup for a few weeks, the human-review queue starts showing you patterns — new evasion families, edge cases your deterministic checks never anticipated. That queue is your next iteration's training signal, not just a safety net.

Where teams get stuck is treating human review as the fallback of last resort instead of an active feedback loop. If you're not systematically mining what routes to human, you're leaving the most valuable signal in the system sitting unread in a ticket queue.

The practical thing I've seen work: tag every human-review case with a reason code at resolution time, even a rough one.

Collapse
 
mudassirworks profile image
Mudassir Khan

the data processing inequality framing is the cleanest explanation i've seen for why LLM judge evals silently degrade: both sides share a channel, so deviations that don't surface as text are invisible to the judge.

we ran into this building a spec adherence checker. the model would 'verify' its output, call it correct, skip required fields not in context. text producer, text evaluator, no signal.

what caught it: a schema validator outside the model loop. don't ask if it's done, check the filesystem.

the part i keep thinking about is the hard server hook — the agent can't log into the box. that's the only tier that's actually model independent. what's your threat model if the agent modifies its own done.yaml before the push?

Collapse
 
zxpmail profile image
zxpmail

Draft reply (English, compressed):

"the agent can't log into the box" — yes, that's the right way to frame the floor. Everything above that is a layer of indirection.

Short answer to the done.yaml threat: this is the DGM fake-log pattern. The decomposition that matters is "who wrote the file the push hook reads?" —
three failure modes:

  1. Agent writes done.yaml directly → put done.yaml in the readonly section (Constraint 1 in the next post — dev.to/zxpmail/wengs-harness-ladde...)
  2. Verify runner reads agent-writeable evidence → runner must only read from paths the agent can't write (§5 in the next post)
  3. Agent self-reports "done" without executing → pre-execution verification catches this (the V4 vector — drops from 0.85 to 0.07 success in the multi-layer experiment)

The honest reframe once you're at layer 3: you're no longer defending against the agent. You're defending against your own harness PR review process —
that's the only path left for the agent to weaken the runner's input boundary. Different threat model, and honestly a harder one.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.