Module 3: Detecting Hallucinations in Code

Types of Hallucinations in Code

Types of Hallucinations in Code

Capsule overview

Not all hallucinations are the same. A fake import can be detected with pip install. An invented parameter can be verified with the documentation. But a function that claims to validate emails and only checks for @ — that requires reading the implementation with a critical eye.

In this capsule you're going to learn the complete taxonomy of hallucinations in code. There are 4 types, from lowest to highest detection difficulty: fake imports, APIs with invented signatures, incorrect parameters, and fabricated logic. For each type you're going to see subtle examples — not import unicorn_magic, but code an experienced developer might accept without question.


The Taxonomy: 4 Types of Hallucinations

Overview

Type 1: Fake Imports
├── Detection difficulty: ⬛⬜⬜⬜ (Low)
├── Dangerousness: ⬛⬛⬜⬜ (Medium)
├── Fails at: import time / pip install
└── Example: from sklearn.metrics import roc_auc_multiclass

Type 2: APIs with Invented Signatures
├── Detection difficulty: ⬛⬛⬜⬜ (Medium)
├── Dangerousness: ⬛⬛⬛⬜ (High)
├── Fails at: runtime when the function is called
└── Example: pd.DataFrame.to_json(orient="dict")

Type 3: Incorrect Parameters
├── Detection difficulty: ⬛⬛⬛⬜ (High)
├── Dangerousness: ⬛⬛⬛⬜ (High)
├── Fails at: runtime, sometimes silently
└── Example: requests.get(url, verify_ssl=True)

Type 4: Fabricated Logic
├── Detection difficulty: ⬛⬛⬛⬛ (Very High)
├── Dangerousness: ⬛⬛⬛⬛ (Very High)
├── Fails at: runtime with specific inputs
└── Example: validate_email() that only checks "@"

Notice that detection difficulty and dangerousness increase together. The hallucinations that are easiest to detect are the least dangerous (because they're detected quickly). The hardest ones are the most dangerous (because they reach production).


Type 1: Fake Imports

What they are

The LLM generates an import of a module, class, or function that doesn't exist in the referenced package. The import may refer to a package that doesn't exist at all, or — more subtly — to a submodule or function that doesn't exist within a real package.

Why they happen

The LLM has seen thousands of imports of a library and extrapolates. If from sklearn.metrics import roc_auc_score exists, the LLM can infer that roc_auc_multiclass also exists because the pattern fits. It doesn't verify — it invents based on linguistic probability.

Subtle examples

Example 1: A submodule that doesn't exist in a real package

# ❌ Hallucination: OAuth2TokenValidator doesn't exist in FastAPI
from fastapi.security import OAuth2TokenValidator

# ✅ What does exist:
from fastapi.security import OAuth2PasswordBearer
from fastapi.security import OAuth2AuthorizationCodeBearer
from fastapi.security import SecurityScopes

Why is it subtle? Because fastapi.security is a real module with real OAuth2 classes. OAuth2TokenValidator sounds like something that should exist alongside OAuth2PasswordBearer. The naming convention is consistent. But it doesn't exist.

Example 2: A function that sounds real in a real module

# ❌ Hallucination: roc_auc_multiclass doesn't exist
from sklearn.metrics import roc_auc_multiclass

# ✅ What does exist:
from sklearn.metrics import roc_auc_score
# For multiclass, use the multi_class parameter:
score = roc_auc_score(y_true, y_pred, multi_class="ovr")

Why is it subtle? Because sklearn has dozens of functions in metrics. A function called roc_auc_multiclass fits the module's style perfectly.

Example 3: A real package, an alias that doesn't exist

# ❌ Hallucination: JSONDecodeError isn't imported like this in Python
from json.exceptions import JSONDecodeError

# ✅ What does exist:
from json import JSONDecodeError
# Or simply:
import json
# And use: json.JSONDecodeError

Why is it subtle? Because JSONDecodeError is a real exception in json. The LLM reasoned that the exceptions would be in an exceptions submodule (like in many other libraries), but in Python's json library that submodule doesn't exist.

Example 4: A testing class that sounds logical

# ❌ Hallucination: AsyncTestClient doesn't exist in httpx
from httpx import AsyncTestClient

# ✅ What does exist:
from httpx import AsyncClient
# For testing with FastAPI:
from httpx import ASGITransport, AsyncClient

Why is it subtle? Because httpx has AsyncClient and it's used a lot for testing. AsyncTestClient sounds like a specialized version for tests — but it doesn't exist.

Warning signs for imports

  • ✅ Import from a Python standard module (os, json, datetime) → Almost certainly correct
  • ✅ Import of a framework's main class (from fastapi import FastAPI) → Almost certainly correct
  • ⚠️ Import of a specific submodule (from fastapi.security import ...) → Verify
  • ⚠️ Import with a compound name that "sounds real" → Verify
  • ❌ Import of something you've never seen in the documentation → Always verify

How to verify quickly

# Method 1: Try to import it
python -c "from fastapi.security import OAuth2TokenValidator"
# ImportError → hallucination confirmed

# Method 2: List the module's contents
python -c "import fastapi.security; print(dir(fastapi.security))"

# Method 3: Search on PyPI
pip show fastapi | grep -i "location"
# Then navigate to the source code

Type 2: APIs with Invented Signatures

What they are

The LLM calls a function or method that does exist, but with a signature (combination of arguments) that doesn't correspond to the real API. The function exists, the arguments it uses don't.

Why they happen

The LLM has seen the same function used with different arguments in different versions, in different wrappers, and in different contexts. It mixes patterns and generates a combination that never existed in any version.

Subtle examples

Example 1: A method with an invented argument value

import pandas as pd

df = pd.DataFrame({"name": ["Alice", "Bob"], "age": [30, 25]})

# ❌ Hallucination: "dict" is not a valid value of orient
result = df.to_json(orient="dict")

# ✅ Valid values of orient:
# "split", "records", "index", "columns", "values", "table"
result = df.to_json(orient="records")

Why is it subtle? Because orient is a real parameter of to_json(), and "dict" sounds like a logical serialization format (in fact, df.to_dict() exists as a separate method). The LLM mixed to_json(orient=...) with to_dict().

Example 2: A function with an argument from another version

import jwt

token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
secret = "my-secret-key"

# ❌ Hallucination: verify=True doesn't exist as a parameter
decoded = jwt.decode(token, secret, algorithms=["HS256"], verify=True)

# ✅ The correct thing in modern PyJWT:
decoded = jwt.decode(
    token, 
    secret, 
    algorithms=["HS256"],
    options={"verify_signature": True}
)

Why is it subtle? Because verify sounds like a logical parameter for a JWT decode function. And in old versions of PyJWT or in other JWT libraries, a similar parameter existed. The LLM mixed the current API with the legacy one.

Example 3: An ORM method with an incorrect argument

from sqlalchemy import select
from sqlalchemy.orm import Session

# ❌ Hallucination: eager_load isn't a parameter of select()
stmt = select(User).where(User.active == True).eager_load(User.posts)

# ✅ The correct thing in SQLAlchemy:
from sqlalchemy.orm import joinedload
stmt = select(User).where(User.active == True).options(joinedload(User.posts))

Why is it subtle? Because eager_load is a real ORM concept (eager loading vs lazy loading). The LLM used the concept's name as if it were a method, when the real method is options(joinedload(...)).

Example 4: A constructor with a parameter that doesn't exist

from pydantic import BaseModel, Field

class UserCreate(BaseModel):
    # ❌ Hallucination: unique=True isn't a parameter of Field()
    email: str = Field(..., unique=True, description="User email")
    name: str = Field(..., min_length=2, max_length=100)

# ✅ Field() accepts: default, alias, title, description,
#    gt, ge, lt, le, min_length, max_length, pattern, etc.
#    "unique" is a database concept, not a Pydantic validation

Why is it subtle? Because unique=True is a real constraint — in SQLAlchemy, in Django ORM, in many ORMs. But Pydantic validates data, it doesn't define database schemas. The LLM mixed the domains.

Warning signs for invented APIs

  • ✅ Standard use of a function with documented arguments → Probably correct
  • ⚠️ A real function with an argument you haven't seen before → Verify
  • ⚠️ A chained method that "sounds like" a concept → Verify (.eager_load, .with_cache, etc.)
  • ❌ A parameter that crosses domains (a DB concept in validation, an ORM concept in an API) → Almost certainly a hallucination

How to verify quickly

# Method 1: Inspect the function's signature
import inspect
import pandas as pd

print(inspect.signature(pd.DataFrame.to_json))
# Shows all the real parameters

# Method 2: Use help()
help(pd.DataFrame.to_json)
# Shows documentation with parameters

# Method 3: Verify in the official documentation
# https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.to_json.html

Type 3: Incorrect Parameters

What they are

The LLM uses a real function with a parameter that sounds correct but isn't the real name of the parameter. Unlike Type 2, here the error is in the name of the parameter, not in its value or in the existence of the function.

Why they happen

LLMs see naming patterns in APIs. If one library uses timeout and another uses request_timeout, the LLM can mix the names. If a concept is called "verify" in one library and "validate" in another, the LLM can cross the names.

Subtle examples

Example 1: A descriptive name that isn't the real one

import requests

# ❌ Hallucination: the parameter is "verify", not "verify_ssl"
response = requests.get(
    "https://api.example.com/data",
    verify_ssl=True,
    timeout=30
)

# ✅ The correct thing:
response = requests.get(
    "https://api.example.com/data",
    verify=True,  # controls SSL verification
    timeout=30
)

Why is it subtle? verify_ssl is a more descriptive name than verify. In many other libraries and configurations, the parameter is called exactly verify_ssl or ssl_verify. The LLM used the name that makes the most semantic "sense," not the one the library uses.

Example 2: A parameter with a similar but different name

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()

# ❌ Hallucination: the parameter is "allow_origins", not "allowed_origins"
app.add_middleware(
    CORSMiddleware,
    allowed_origins=["http://localhost:3000"],
    allowed_methods=["*"],
    allowed_headers=["*"],
)

# ✅ The correct thing:
app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:3000"],
    allow_methods=["*"],
    allow_headers=["*"],
)

Why is it subtle? The difference is allow_ vs allowed_. Semantically they're almost identical. And allowed_origins is grammatically more natural in English. But FastAPI/Starlette uses allow_origins (without the "d").

Example 3: A parameter from another library

import logging

# ❌ Hallucination: "log_level" isn't a parameter of basicConfig
logging.basicConfig(
    log_level=logging.INFO,
    format="%(asctime)s - %(message)s"
)

# ✅ The correct thing:
logging.basicConfig(
    level=logging.INFO,  # it's "level", not "log_level"
    format="%(asctime)s - %(message)s"
)

Why is it subtle? log_level sounds more specific than level, and it's the name used in the configurations of many frameworks (like uvicorn, gunicorn, and Django). But logging.basicConfig() simply uses level.

Example 4: A parameter with an extra prefix

from sqlalchemy import create_engine

# ❌ Hallucination: "max_pool_size" isn't the correct name
engine = create_engine(
    "postgresql://user:pass@localhost/db",
    max_pool_size=20,
    pool_timeout=30
)

# ✅ The correct thing:
engine = create_engine(
    "postgresql://user:pass@localhost/db",
    pool_size=20,        # not "max_pool_size"
    pool_timeout=30      # this one is correct
)

Why is it subtle? pool_timeout exists and is correct. max_pool_size follows the same naming pattern (pool_*) but the real parameter is pool_size. The "max_" prefix is a logical addition by the LLM but incorrect.

The special danger of this type

Incorrect parameters are especially dangerous because in Python, with **kwargs, an incorrect parameter can be silently ignored:

# Many functions accept **kwargs
# A parameter with an incorrect name is simply ignored
# There's no error — but there's also no effect

import requests

# This does NOT raise an error — but verify_ssl does nothing
response = requests.get(url, verify_ssl=False)
# SSL is still being verified because "verify" was never False
# In production, you think you disabled SSL verification
# but you didn't

That's why this type has High dangerousness: the code works without an error, but the parameter has no effect. Your intent isn't carried out and you get no warning.

Warning signs for incorrect parameters

  • ✅ A parameter you've personally used many times → Probably correct
  • ⚠️ A parameter with a "more descriptive" name than usual → Verify
  • ⚠️ A parameter that exists in another similar library → Verify
  • ⚠️ A parameter that sounds correct but you've never seen it in the docs → Verify
  • ❌ Two parameters of the same type with different naming in the same call → Suspicious

How to verify quickly

# Method 1: Inspect the accepted parameters
import inspect
import requests

sig = inspect.signature(requests.get)
print(sig.parameters.keys())
# dict_keys(['url', 'params', 'kwargs'])
# Then see the documentation for kwargs

# Method 2: Use an IDE with autocomplete
# Modern IDEs show the available parameters
# If a parameter doesn't appear in autocomplete, be suspicious

# Method 3: Quick test
import requests
try:
    requests.get("https://httpbin.org/get", verify_ssl=True)
    print("No error — but does it work?")
except TypeError as e:
    print(f"Error: {e}")

Type 4: Fabricated Logic

What they are

The LLM generates a function with a descriptive name and a correct docstring, but the implementation doesn't do what it says. Unlike a bug (where the developer tried to implement something and got it wrong), here the LLM generated an implementation that was never correct — it fabricated logic that sounds plausible but doesn't correspond to the declared purpose.

Why they happen

LLMs are excellent at generating code that looks structurally correct: good function names, coherent docstrings, correct types. But when the logic requires specific domain knowledge — mathematical formulas, validation algorithms, business rules — the LLM can generate an implementation that "seems reasonable" without being so.

Subtle examples

Example 1: A superficial validation that looks complete

import re

def validate_email(email: str) -> bool:
    """
    Validates that the email address is properly formatted
    according to RFC 5322 standards.
    """
    if not email or not isinstance(email, str):
        return False
    return "@" in email and "." in email.split("@")[-1]

Why is it subtle? The function has:

  • ✅ A descriptive name
  • ✅ A docstring that mentions RFC 5322
  • ✅ Type hints
  • ✅ Input validation (not empty, is string)
  • ✅ Checks for @ and . in the domain
  • ❌ But it accepts: "a@b.c", "@domain.com", "user@.com", "us er@domain.com"

Real email validation is significantly more complex. The LLM generated something that passes 80% of cases — but fails on edge cases that matter.

Example 2: An incorrect statistical calculation

from typing import List

def calculate_percentile(data: List[float], percentile: int) -> float:
    """
    Calculates the given percentile of a dataset.
    Uses the standard interpolation method.
    
    Args:
        data: List of numerical values
        percentile: Percentile to calculate (0-100)
    
    Returns:
        The percentile value
    """
    if not data:
        raise ValueError("Data cannot be empty")
    if not 0 <= percentile <= 100:
        raise ValueError("Percentile must be between 0 and 100")
    
    sorted_data = sorted(data)
    index = (percentile / 100) * len(sorted_data)
    
    if index == int(index):
        return sorted_data[int(index)]
    
    lower = sorted_data[int(index)]
    upper = sorted_data[int(index) + 1]
    return (lower + upper) / 2

Why is it subtle? The function:

  • ✅ Has correct input validation
  • ✅ Sorts the data
  • ✅ Calculates an index based on the percentile
  • ❌ The index calculation is incorrect (doesn't use standard interpolation)
  • ❌ Fails with index = len(data) (IndexError)
  • ❌ The interpolation doesn't correspond to any standard method (np.percentile uses well-defined methods)

The LLM generated something that produces reasonable numbers for most inputs — but doesn't implement the correct percentile calculation.

Example 3: Sanitization that doesn't sanitize

import re

def sanitize_sql_input(user_input: str) -> str:
    """
    Sanitizes user input to prevent SQL injection attacks.
    Removes dangerous SQL keywords and characters.
    """
    dangerous_keywords = [
        "DROP", "DELETE", "INSERT", "UPDATE",
        "SELECT", "UNION", "ALTER", "CREATE"
    ]
    
    sanitized = user_input
    for keyword in dangerous_keywords:
        sanitized = re.sub(
            keyword, "", sanitized, flags=re.IGNORECASE
        )
    
    sanitized = sanitized.replace("'", "''")
    sanitized = sanitized.replace(";", "")
    
    return sanitized

Why is it subtle? The function:

  • ✅ Has a correct name and an appropriate docstring
  • ✅ Lists dangerous SQL keywords
  • ✅ Removes the keywords
  • ✅ Escapes single quotes
  • ✅ Removes semicolons
  • ❌ But blacklist-based sanitization is fundamentally incorrect for preventing SQL injection
  • ❌ Easy bypass: "SELSELECTECT" → after removing SELECT, SELECT remains
  • ❌ Doesn't protect against injection with comments: /**/
  • ❌ The real solution is to use parameterized queries, not to sanitize input

The function gives a false sense of security. A developer who uses it thinks they're protected against SQL injection, but they aren't.

Example 4: A hash that isn't secure

import hashlib

def hash_password(password: str) -> str:
    """
    Securely hashes a password for storage.
    Uses SHA-256 hashing algorithm.
    """
    return hashlib.sha256(password.encode()).hexdigest()

def verify_password(password: str, hashed: str) -> bool:
    """Verifies a password against its hash."""
    return hash_password(password) == hashed

Why is it subtle? The function:

  • ✅ Uses hashlib (standard library, not invented)
  • ✅ SHA-256 is a real and respected algorithm
  • ✅ The verification is logically correct
  • ❌ SHA-256 without a salt is vulnerable to rainbow table attacks
  • ❌ SHA-256 is too fast for passwords (allows brute force)
  • ❌ Doesn't use bcrypt, scrypt, or argon2 (algorithms designed for passwords)
  • ❌ No salt, no iterations, no key stretching

A developer who doesn't know the difference between generic hashing and password hashing would accept this without question.

Warning signs for fabricated logic

  • ✅ An implementation of something you've done before and recognize → Probably correct
  • ⚠️ A function with a docstring that mentions a standard (RFC, algorithm) → Verify that the implementation matches
  • ⚠️ A security function implemented from scratch → Almost always better to use a library
  • ⚠️ A math/statistical function → Verify formulas against a reference
  • ❌ A function that "sanitizes" or "validates" with blacklist logic → Suspicious
  • ❌ A hashing/encryption function implemented manually → Always use a specialized library

How to verify

# Method 1: Quick test with edge cases
def test_validate_email():
    assert validate_email("user@domain.com") == True
    assert validate_email("@domain.com") == False      # No local part
    assert validate_email("user@.com") == False         # No domain
    assert validate_email("us er@domain.com") == False  # Space
    assert validate_email("a@b.c") == False             # TLD too short
    # If any fails, the validation is insufficient

# Method 2: Compare with a known library
# For email validation:
from email_validator import validate_email as real_validate
# For password hashing:
from passlib.hash import bcrypt
# For SQL: use parameterized queries, never sanitize

Dangerousness Map

When to worry more?

                    Easy to detect       Hard to detect
                    ──────────────────────────────────────────
Low impact         │ Fake import from   │ Silently ignored   │
                   │ a standard library │ parameter          │
                   │ (immediate crash)  │ (no error)         │
                   ├────────────────────┼─────────────────────┤
High impact        │ Fake import in     │ Security logic     │
                   │ critical code      │ that "looks" right │
                   │ (crash on deploy)  │ (reaches prod)     │
                   ──────────────────────────────────────────

Most dangerous quadrant: 
Hard to detect + High impact = Fabricated logic in security

Verification priority

When you review AI-generated code, the priority order should be:

  1. Security logic (authentication, authorization, encryption, sanitization)
  2. Business logic (financial calculations, domain validations)
  3. API parameters (especially the ones that can be silently ignored)
  4. Function signatures (verify against documentation)
  5. Imports (verify that they exist)

Don't review imports first because they're the easiest to detect. Review security logic first because it's the most dangerous to overlook.


Connection to the Project

Application to the capstone project

In the capstone project in module 8, the codebase contains hallucinations of all 4 types:

TypeQuantity in the projectDifficulty
Fake imports1-2Low (you find them with a linter/import)
Invented APIs1Medium (you need to verify docs)
Incorrect parameters1-2High (they can be silently ignored)
Fabricated logic1Very high (you need to read and understand the implementation)

Your ability to quickly classify the type of hallucination helps you choose the correct verification technique: a linter for imports, docs for APIs, tests for logic.

From diagnosis to action

This capsule gave you the taxonomy (which types exist). Capsules 03 and 04 give you the specific detection techniques for each type. Capsule 05 gives you the automated tools. And capsule 06 is where you demonstrate that you can detect them.


Troubleshooting

Problem 1: "I don't see the difference between a bug and a fabricated-logic hallucination"

Cause: The line is subtle. A bug is a human programmer's error implementing known logic. A hallucination is logic the LLM invented without verifying.

Solution: The key question is: does the code try to implement something real or did it invent its own version? If calculate_percentile() uses numpy.percentile internally but with a bug, it's a bug. If it implements an algorithm the LLM invented, it's a hallucination. In practice, the solution is the same: verify against a reference and tests.

Problem 2: "How do I know if a parameter is real or invented without opening the docs?"

Cause: You don't have to memorize every parameter of every library. That's impossible.

Solution: Your verification instinct should activate for parameters you haven't personally used before. If it's the first time you see a parameter, verify it. If you've used it 100 times, trust it. The simple rule: "if I haven't used it before, I verify it."

Problem 3: "Types 3 and 4 seem too hard to detect"

Cause: They are — that's why capsule 05 gives you automated tools.

Solution: Your human eye isn't going to detect 100% of hallucinations. No one can. That's why you combine manual verification (your trained eye) with automated verification (linters, type checkers, tests). Capsules 03 and 04 train your eye. Capsule 05 gives you the tools that cover where your eye fails.

Problem 4: "Is it possible for a hallucination not to cause problems?"

Cause: Yes, it's possible. An invented parameter that is silently ignored may not cause visible problems.

Solution: Not causing visible problems doesn't mean it's not a problem. A verify_ssl=False that is silently ignored means SSL verification is still active — which is good, accidentally. But if your intent was to disable SSL (maybe for local testing), your code doesn't do what you think it does. The hallucination doesn't cause a crash, but it causes confusion and problematic future maintenance.


Exercises

Exercise 1: Classify hallucinations (Easy)

Classify each of these errors as Type 1 (Import), Type 2 (API), Type 3 (Parameter), Type 4 (Logic), or "Not a hallucination":

  1. from collections import OrderedDefaultDict
  2. hashlib.sha256(data).hexdigest() when bcrypt is needed for passwords
  3. os.path.exists() returns True but the file has no read permissions
  4. pd.read_csv("data.csv", delimiter=",")
  5. json.loads(data, encoding="utf-8")
See solution
  1. Type 1 (Fake import): OrderedDefaultDict doesn't exist in collections. OrderedDict and defaultdict exist separately. The LLM combined both.
  2. Type 4 (Fabricated logic): hashlib.sha256 works correctly — but using it for passwords is an incorrect security decision. The function exists and works; the logic of using it for passwords is the hallucination.
  3. Not a hallucination: This is real behavior of os.path.exists(). It returns True if the path exists, regardless of permissions. It's a known limitation, not a hallucination.
  4. Not a hallucination: delimiter is a real parameter of pd.read_csv(). It's correct.
  5. Type 3 (Incorrect parameter): In Python 3.9+, json.loads() no longer accepts the encoding parameter (it was removed). In earlier versions it was deprecated. The LLM generated code from an earlier version.

Exercise 2: Identify the hallucination (Medium)

In each pair, one is correct and the other is a hallucination. Which is which?

Pair A:

# Option 1
from typing import Annotated
from fastapi import Depends, Query

# Option 2
from typing import Annotated
from fastapi import Depends, QueryParam

Pair B:

# Option 1
response = requests.post(url, json=data, timeout=30)

# Option 2
response = requests.post(url, json_data=data, timeout=30)

Pair C:

# Option 1
from pathlib import Path
content = Path("file.txt").read_text(encoding="utf-8")

# Option 2
from pathlib import Path
content = Path("file.txt").read_contents(encoding="utf-8")
See solution

Pair A: Option 1 is correct. Query is the real function/class in FastAPI. QueryParam doesn't exist — the LLM generated a name that sounds more descriptive but isn't the real one. Type 1 (Fake import).

Pair B: Option 1 is correct. json=data is the correct parameter of requests.post(). json_data doesn't exist — the LLM used a more descriptive but incorrect name. Type 3 (Incorrect parameter).

Pair C: Option 1 is correct. read_text() is the real method of pathlib.Path. read_contents() sounds similar but doesn't exist. Type 2 (Invented API).

Exercise 3: Find the subtle hallucination (Medium-Hard)

This code has exactly 1 hallucination. Find it:

from fastapi import FastAPI, HTTPException, Depends
from fastapi.security import OAuth2PasswordBearer
from pydantic import BaseModel, EmailStr
from typing import Optional
import jwt
from datetime import datetime, timedelta

app = FastAPI()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

SECRET_KEY = "your-secret-key"
ALGORITHM = "HS256"

class UserCreate(BaseModel):
    email: EmailStr
    password: str
    full_name: Optional[str] = None

def create_access_token(data: dict, expires_delta: timedelta = None):
    to_encode = data.copy()
    expire = datetime.utcnow() + (expires_delta or timedelta(minutes=15))
    to_encode.update({"exp": expire})
    return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)

def get_current_user(token: str = Depends(oauth2_scheme)):
    try:
        payload = jwt.decode(
            token, SECRET_KEY, 
            algorithms=[ALGORITHM],
            verify=True
        )
        return payload
    except jwt.ExpiredSignatureError:
        raise HTTPException(status_code=401, detail="Token expired")
    except jwt.InvalidTokenError:
        raise HTTPException(status_code=401, detail="Invalid token")
See solution

The hallucination is in jwt.decode():

payload = jwt.decode(
    token, SECRET_KEY,
    algorithms=[ALGORITHM],
    verify=True  # ← HALLUCINATION: this parameter doesn't exist
)

In modern PyJWT, verify=True isn't a valid parameter of jwt.decode(). The correct thing is:

payload = jwt.decode(
    token, SECRET_KEY,
    algorithms=[ALGORITHM],
    options={"verify_signature": True}
)

It's a Type 3 (Incorrect parameter). The subtle part: all the rest of the code is correct — OAuth2PasswordBearer, jwt.encode, jwt.ExpiredSignatureError, jwt.InvalidTokenError — everything exists and works. Only the verify=True parameter is invented.

Note: depending on the PyJWT version, verify=True might not cause an error (it would be ignored as **kwargs), but it would have no effect. Signature verification in modern PyJWT is enabled by default, so in this case the hallucination doesn't cause a security problem — but the parameter doesn't do what the developer thinks.

Exercise 4: Detect fabricated logic (Hard)

This function claims to calculate the Levenshtein distance between two strings. Is the implementation correct?

def levenshtein_distance(s1: str, s2: str) -> int:
    """
    Calculates the Levenshtein (edit) distance between two strings.
    Returns the minimum number of single-character edits 
    (insertions, deletions, substitutions) required to change 
    one string into the other.
    """
    if len(s1) < len(s2):
        return levenshtein_distance(s2, s1)
    
    if len(s2) == 0:
        return len(s1)
    
    previous_row = range(len(s2) + 1)
    
    for i, c1 in enumerate(s1):
        current_row = [i + 1]
        
        for j, c2 in enumerate(s2):
            insertions = previous_row[j + 1] + 1
            deletions = current_row[j] + 1
            substitutions = previous_row[j] + (c1 != c2)
            current_row.append(min(insertions, deletions, substitutions))
        
        previous_row = current_row
    
    return previous_row[-1]
See solution

This implementation is correct. It's not a hallucination.

It's the standard iterative implementation of the Levenshtein distance using dynamic programming with space optimization (it only keeps the previous row in memory instead of the full matrix).

How to verify:

assert levenshtein_distance("kitten", "sitting") == 3
assert levenshtein_distance("", "hello") == 5
assert levenshtein_distance("same", "same") == 0
assert levenshtein_distance("a", "b") == 1

The point of this exercise: not everything AI generates is incorrect. Part of being a good detective is knowing when the code is correct. If you treat everything as suspicious, you lose time and productivity.

Exercise 5: Create your own table (Hard)

For a library you use frequently in your work, generate a table with:

Plausible hallucinationWhy it sounds realThe correct thingType

Generate at least 3 plausible hallucinations for your library. If you can't think of any, use FastAPI or requests.

See solution (example with FastAPI)
Plausible hallucinationWhy it sounds realThe correct thingType
from fastapi import RouterSimilar to Flask's Blueprint, intuitive namefrom fastapi import APIRouterType 1
@app.post("/users", response_type=User)response_type sounds logical@app.post("/users", response_model=User)Type 3
app.include_router(router, tags="users")tags as a string sounds reasonableapp.include_router(router, tags=["users"]) — tags is a listType 3
HTTPException(status=404, message="Not found")status and message are genericHTTPException(status_code=404, detail="Not found")Type 3

The key: all these errors are of the "sounds more intuitive than the real thing" type. LLMs tend to generate the most natural/descriptive name, not necessarily the one the library uses.


Summary

In this capsule you learned:

  • Hallucinations are classified into 4 types from lowest to highest detection difficulty
  • Type 1 (Fake imports): Modules, classes, or functions that don't exist → Detect with a linter/import
  • Type 2 (Invented APIs): Real functions with signatures that don't exist → Detect with docs
  • Type 3 (Incorrect parameters): Parameter names that sound good but aren't the real ones → Dangerous because they can be silently ignored
  • Type 4 (Fabricated logic): Implementations that look correct but don't do what they say → The most dangerous, they require reading and understanding the implementation
  • Dangerousness increases with detection difficulty
  • Security hallucinations (Type 4) are the ones to prioritize in the review
  • The dangerous hallucinations are the subtle ones — the ones that look like the correct thing

Next capsule: Hallucinations in Imports and APIs — specific techniques for detecting types 1 and 2.


Additional resources

  1. PyPI — Python Package Index - Verify that a Python package exists
  2. Python Official Documentation - Reference for Python standard modules
  3. FastAPI Documentation - API reference for verifying imports and parameters
  4. pandas API Reference - Verify pandas methods and parameters
  5. Real Python — Common Python Gotchas - Common errors that look like hallucinations but are real behavior
  6. OWASP — Input Validation Cheat Sheet - Why blacklist-based sanitization is insufficient

Debugging & Code Review with Claude Code — Module 3, Capsule 02 Claude Code Agentic Development Path — Guide #6 of 11