Why AI Can't Check Its Own Code — 8 Attempts That Prove It
AI Hallucinations Code Verification LLM Chain of Verification92% 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
| Version | Lines | What It Adds | Verifies Against Reality? |
|---|---|---|---|
| 1. Basic CoVe | 10 | Self-check concept | No |
| 2. + Error handling | 15 | Graceful failures | No |
| 3. + Retry | 20 | Resilience | No |
| 4. + Circuit breaker | 35 | API protection | No |
| 5. + Persistence | 80 | State across restarts | No |
| 6. + Thread safety | 50 | Concurrency (with a deadlock) | No |
| 7. + Async | 25 | Non-blocking calls | No |
| 8. + Full jitter | 12 | AWS-grade backoff | No |
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.
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.