← Back to Blog

Why AI Can't Check Its Own Code — 8 Attempts That Prove It

28 June 2026 · 8 min read · Craig, Founder of Thuban
AI Hallucinations Code Verification LLM Chain of Verification

92% of developers now use AI coding tools. GitHub Copilot, Cursor, Claude Code — they write code that looks perfect but imports modules that don't exist, calls APIs that were deprecated three years ago, and references functions that were never defined.

The industry's answer? Make the AI check itself. Chain of Verification, Self-Consistency, Reflexion — techniques where the LLM reviews its own output and decides if it's correct.

We tried. Eight times. Each attempt added more sophisticated engineering. None of them solved the problem.

Attempt 1: Basic Chain of Verification

The idea is elegant: generate an answer, break it into atomic claims, verify each one independently, then reconstruct the answer using only verified claims.

# Chain of Verification
def chain_of_verification(question):
    initial = llm(f"Answer: {question}")
    claims = llm(f"List atomic claims from: {initial}")
    verified = []
    for claim in claims:
        v = llm(f"Is this true? {claim} Answer yes/no.")
        if "yes" in v.lower():
            verified.append(claim)
    return llm(f"Final answer using only: {verified}")

The problem: The same LLM that made the claim is also the judge. If Copilot invents import utils.quantum_helper, and you ask the same model "does utils.quantum_helper exist?", it'll say yes — because it invented it in the first place. It's the fox guarding the henhouse.

Attempt 2: Add Error Handling

The first version crashes if any verification call fails. Let's fix that.

def chain_of_verification(question):
    try:
        initial = llm(f"Answer: {question}")
        claims = llm(f"List atomic claims from: {initial}")
        verified = []
        for claim in claims:
            try:
                v = llm(f"Is this true? {claim} Answer yes/no.")
                if "yes" in v.lower():
                    verified.append(claim)
            except Exception:
                continue  # Skip failed verifications
        return llm(f"Final answer using only: {verified}")
    except Exception as e:
        return f"Error: {str(e)}"

The problem: Now it gracefully handles failures. And silently skips claims it can't verify. But the verification itself? Still the same LLM asking itself if it's right. We made the pipe more resilient. The answer is still unverified.

Attempt 3: Retry with Exponential Backoff

API calls fail. Rate limits hit. Let's add proper retry logic.

import time

def llm_with_retry(prompt, max_retries=3):
    for attempt in range(max_retries):
        try:
            return llm(prompt)
        except Exception:
            if attempt == max_retries - 1:
                raise
            time.sleep(2 ** attempt)  # Exponential backoff

The problem: We're retrying a question to the same model that hallucinated the answer. Asking three times doesn't make a wrong answer right. We're making the circular validation more reliable, not more accurate.

Attempt 4: Circuit Breaker

If the LLM API is down, stop hammering it. Classic distributed systems pattern.

class CircuitBreaker:
    def __init__(self, failure_threshold=5, recovery_timeout=30):
        self.failure_threshold = failure_threshold
        self.recovery_timeout = recovery_timeout
        self.failures = 0
        self.last_failure_time = None
        self.state = "CLOSED"  # CLOSED, OPEN, HALF_OPEN

    def call(self, func, *args):
        if self.state == "OPEN":
            if time.time() - self.last_failure_time > self.recovery_timeout:
                self.state = "HALF_OPEN"
            else:
                raise Exception("Circuit breaker open")
        try:
            result = func(*args)
            self.failures = 0
            self.state = "CLOSED"
            return result
        except Exception:
            self.failures += 1
            self.last_failure_time = time.time()
            if self.failures >= self.failure_threshold:
                self.state = "OPEN"
            raise

The problem: 35 lines of defensive infrastructure. The circuit breaker protects the API. It does nothing to protect the output. We're at 35 lines and still haven't verified a single claim against reality.

Attempt 5: Persistent State

What if the service restarts? Let's persist the circuit breaker state to disk.

class CircuitBreaker:
    def __init__(self, failure_threshold=5, recovery_timeout=30,
                 state_file="cb_state.json"):
        self.state_file = state_file
        self.load_state()

    def load_state(self):
        if os.path.exists(self.state_file):
            with open(self.state_file) as f:
                data = json.load(f)
                self.failures = data.get("failures", 0)
                self.state = data.get("state", "CLOSED")

    def save_state(self):
        with open(self.state_file, "w") as f:
            json.dump({"failures": self.failures,
                       "state": self.state}, f)
    # ... rest of circuit breaker with save_state() calls

The problem: 80 lines now. And in the real version, the entire class was accidentally duplicated — copy-pasted twice in the same file. The very kind of bug that AI coding tools create and that grows unnoticed as code complexity increases. Still no verification against reality.

Attempt 6: Thread Safety

Multiple verification calls running concurrently? Need thread safety.

from threading import Lock

class CircuitBreaker:
    def __init__(self, ...):
        self.lock = Lock()

    def call(self, func, *args):
        with self.lock:
            if self.state == "OPEN":
                # ... check recovery
        try:
            result = func(*args)
            with self.lock:
                self.save_state()  # BUG: save_state() also acquires self.lock
            return result

The problem: Deadlock. call() acquires the lock, then calls save_state() which tries to acquire the same non-reentrant lock. The verification infrastructure now has a concurrency bug. We're building increasingly sophisticated plumbing around a fundamentally flawed approach, and the plumbing itself needs verifying.

Attempt 7: Go Async

Modern LLM APIs are async. Let's use asyncio for concurrent verification.

import asyncio

async def llm_with_backoff(prompt, max_retries=3, strategy="exponential"):
    for attempt in range(max_retries):
        try:
            return await llm(prompt)
        except Exception:
            if attempt == max_retries - 1:
                raise
            if strategy == "exponential":
                delay = (2 ** attempt) + random.uniform(0, 1)
            await asyncio.sleep(delay)

The problem: asyncio.Lock() solves the deadlock from Attempt 6. The jitter prevents thundering herd. This is genuinely good async engineering. But it's still asking the LLM to verify its own claims. We've made the wrong thing work perfectly.

Attempt 8: Full Jitter Backoff

AWS recommends full jitter for distributed systems. Let's implement it.

async def llm_with_jitter_backoff(prompt, max_retries=3, base_delay=1):
    for attempt in range(max_retries):
        try:
            return await llm(prompt)
        except Exception:
            if attempt == max_retries - 1:
                raise
            # Full jitter (AWS recommended)
            delay = random.uniform(0, base_delay * (2 ** attempt))
            await asyncio.sleep(delay)

The problem: Production-grade retry logic. Beautiful code. The delivery mechanism is now bulletproof. The verification is still circular.

The Scorecard

VersionLinesWhat It AddsVerifies Against Reality?
1. Basic CoVe10Self-check conceptNo
2. + Error handling15Graceful failuresNo
3. + Retry20ResilienceNo
4. + Circuit breaker35API protectionNo
5. + Persistence80State across restartsNo
6. + Thread safety50Concurrency (with a deadlock)No
7. + Async25Non-blocking callsNo
8. + Full jitter12AWS-grade backoffNo

Eight iterations. The right column never changes.

The Real Solution: Check Against Reality

The problem with every approach above is the same: the LLM is checking its own homework. Chain of Verification, Self-Consistency, Reflexion — they all ask the model that generated the code whether the code is correct. That's circular validation.

What actually works is external verification — checking claims against ground truth:

# Thuban's approach — external verification
def thuban_verify(codebase):
    files = scan_all_files(codebase)
    for file in files:
        for imp in extract_imports(file):
            # Check if the module ACTUALLY EXISTS
            if not (file_exists(imp.path) or package_exists(imp.name)):
                flag_hallucination(imp)

        for api in extract_api_calls(file):
            # Check if the method ACTUALLY EXISTS on that module
            if not api_method_exists(api.module, api.method):
                flag_hallucination(api)

        for dep in extract_dependencies(file):
            # Check if the package is ACTUALLY current
            if is_deprecated(dep.package, dep.version):
                flag_risk(dep)

    return HealthReport(score, issues, fix_roadmap)

No LLM in the verification loop. No retry logic needed because we're not calling an API that hallucinates. No circuit breaker because file systems don't have opinions.

The difference in one line:

CoVe asks: "Do you think this import is valid?"

Thuban checks: "Does this file exist on disk? Does this package exist in npm? Does this API method exist in the module?"

One is a question. The other is a fact.

Stop asking. Start checking.

External verification catches what 8 iterations of LLM self-checking cannot.

Try It Yourself

Scan your codebase in 2 seconds

npx thuban scan .

Zero install. Zero config. Zero cloud. Checks against reality, not opinion.

thuban.dev — Free for up to 100 files, 5 scans/month

Craig is the founder of Thuban and runs five production codebases that are scanned daily. If it's good enough for systems handling real money, it's good enough for yours.