The guardrail that invented a genre
I asked my recommender for "something aggressive and intense for a heavy workout" and it confidently returned an ambient track at number one. Nothing errored. Nothing logged. The code was AI-written, it ran clean, and it was quietly wrong — which is the worst failure mode there is.
Where this came from
This is the capstone for CodePath's AI-110, Foundations of AI Engineering. The brief was to take one of your own earlier projects and extend it into a complete applied AI system — not to start something new. That constraint turned out to be the useful part, because it meant the thing I was adding an LLM to was code I had already written, tested, and found the limits of.
The modules built on each other in a way that matters here:
| Module | What I built | What carried forward |
|---|---|---|
| 1 | Debugging a deliberately broken AI-written game | The habit of treating generated code as a suspect, not a gift |
| 2 | A pet-care system designed from a UML diagram first | Modular boundaries, and diagrams that match the code |
| 3 | The original VibeMatch — content-based recommender, model card, bias analysis | The entire scoring engine, unchanged |
| 5 | An agentic bug-analysis tool with a risk assessor | Plan/act/check structure, and reliability as a first-class concern |
| Capstone | The AI Concierge layer over Module 3 | — |
The Module 3 version worked, but it had a flat limitation: the user had to hand-author a Python dictionary of preferences. There was no way for an actual person to just say what they wanted. That's the gap the LLM fills — and, notably, the only gap it fills. Everything else stayed as it was.
The system in one paragraph
VibeMatch turns a plain-English request into a ranked playlist. It runs plan → act → check → explain: Claude parses the free text into a structured taste profile, a guardrail validates that profile, a deterministic engine scores and ranks every song, and Claude rewrites the real scoring reasons into friendly prose.
The design rule I set at the start was that the LLM handles language and deterministic code handles the decision. The model never picks a song. It only turns words into a profile, and turns scores into sentences. Everything that has to be correct and reproducible stays in plain Python that I can unit-test.
The bug
The fallback parser scans the request for a genre and a mood that exist in the catalog. If it finds neither, it has to decide what to do. The first version — which I'd accepted from an AI suggestion because it looked reasonable and the tests passed — did this:
# The original. Note the fallbacks on the last two lines.
def _fallback_parse(query, genres, moods):
q = query.lower()
genre = next((g for g in genres if g in q), genres[0]) # <--
mood = next((m for m in moods if m in q), moods[0]) # <--
...
genres and moods are sorted lists built from the catalog.
So genres[0] is "ambient" and moods[0] is
"aggressive" — alphabetically first, semantically meaningless.
Now run the query "something aggressive and intense for a heavy workout."
There is no genre word in that sentence. No rock, no
metal, no edm. So the parser fell through to
genres[0] and decided the user's favourite genre was
ambient.
Genre is the heaviest lever in the scoring engine — a genre match is worth
+2.0, more than mood and energy combined. So one invented word at the
top of the pipeline dragged an ambient track to first place for a workout request.
| Before the fix | After | |
|---|---|---|
| Parsed genre | ambient (invented) | None |
| Top pick | an ambient track | Iron Verdict — metal, energy 0.97 |
| Stated reason | "genre match (+2.0)" | "mood match (+1.0); energy close to target (+0.93)" |
| Was it wrong? | Yes | No |
| Did anything complain? | No | — |
Why this is worse than a crash
A crash is honest. It tells you, at the exact moment and line, that your assumption was wrong. This did the opposite: it produced a plausible-looking answer, attached a confident justification to it, and gave me no signal at all.
And the justification was the really uncomfortable part. The explanation step is grounded — it's only allowed to talk about reasons the scoring engine actually produced. That grounding worked perfectly. It faithfully reported "genre match." The grounding was doing its job on top of a lie that had been told upstream.
Grounding an explanation in your system's reasoning only helps if the reasoning is sound. Otherwise you've built something that explains its mistakes persuasively.
That's the shape of the risk in a lot of LLM products. The generated text isn't the dangerous part. The dangerous part is a confident presentation layer sitting on top of a silent default somewhere further up.
The fix: refuse to guess
The change is two words. Return None instead of the first list item:
genre = next((g for g in genres if g in q), None)
mood = next((m for m in moods if m in q), None)
And make the guardrail treat None as a legitimate answer meaning
no preference, rather than something to fill in:
def validate_profile(profile, genres, moods):
"""Coerce a profile to values the catalog understands.
Unknown genre/mood become None ('no preference') rather than a bogus
default, so we never invent a match the user did not ask for.
"""
safe = dict(profile)
if safe.get("favorite_genre") not in genres:
safe["favorite_genre"] = None
if safe.get("favorite_mood") not in moods:
safe["favorite_mood"] = None
try:
energy = float(safe.get("target_energy", 0.5))
except (TypeError, ValueError):
energy = 0.5
safe["target_energy"] = max(0.0, min(1.0, energy))
safe["likes_acoustic"] = bool(safe.get("likes_acoustic", False))
return safe
With favorite_genre = None the engine simply awards no genre points and
ranks on the signals it genuinely has — mood and energy. The workout query now
surfaces Iron Verdict (aggressive, energy 0.97) at number one, and its
stated reasons mention only mood and energy, because that's all that actually
matched.
favorite_genre and favorite_mood are
enums built from the loaded catalog at runtime, not a hardcoded list. The
model can't name a genre that doesn't exist, and the allowed values can never drift
out of sync with the data. But the guardrail still runs on the model's output
anyway — a schema is a request, not a guarantee.
Locking it down
A fix you can't prove is a fix you'll undo in three months. I added an evaluation harness that runs the whole system on fixed inputs and asserts the invariants directly, so the specific failure can't come back quietly:
$ python eval_harness.py
VibeMatch evaluation harness — 17 songs, 4 end-to-end cases
[PASS] (fallback) 'high energy pop to get hyped at the gym'
top: Gym Hero [score 2.97]
[PASS] (fallback) 'calm acoustic lofi for late-night studying'
top: Library Rain [score 3.40]
[PASS] (fallback) 'chill reggae for a beach afternoon'
top: Island Time [score 3.73]
[PASS] (fallback) 'something aggressive and intense for a heavy workout'
top: Iron Verdict [score 1.93] <-- the regression case
Guardrail unit checks:
[PASS] clamps energy 5.0 -> 1.0
[PASS] clamps energy -3.0 -> 0.0
[PASS] drops unknown genre -> None
[PASS] drops unknown mood -> None
Summary: 8/8 checks passed (4/4 cases, 4/4 guardrail).
Note what the last four assert. Not "does it work" but "does it refuse to invent." Those are the checks that would have caught the original bug, and they're the ones I care about most.
The other failure — and why it reassured me
Partway through testing, my API account ran out of credits mid-run. The call
returned 400 — credit balance is too low. The system logged a warning,
dropped to the fallback parser, and returned the same ranked list:
[WARNING] LLM parse failed (Error code: 400 - ... credit balance is too low ...) - falling back.
Parsed profile (fallback): {'favorite_genre': 'synthwave', 'favorite_mood': 'moody', ...}
1. Night Drive Loop by Neon Echo [score: 3.75]
Recommended because genre match (+2.0); mood match (+1.0); energy close to target (+0.75).
I hadn't planned that test. It was the most useful one I ran. An unplanned outage in
a dependency is exactly the condition you never simulate properly, and the system
degraded instead of dying — because the fallback was built as a real path from day
one rather than bolted on as an except block.
One smaller thing I noticed while comparing modes: given
target_energy = 0.5, Claude described the songs as "mellow" and
"laid-back." It correctly cited energy as the matching signal, so the grounding
held — but 0.5 is the middle of the range, not the low end. The wording drifted
below the number. Nothing broke, and it's the kind of soft inaccuracy that only
shows up if you read the output rather than checking that it exists.
What I actually took from this
- A default is a decision.
genres[0]looks like defensive programming and is actually a silent assertion about what the user wants. When you don't know, the honest value is "unknown," not the first thing in the list. - Passing tests are not evidence of correctness. The original code passed everything I had. My tests checked that the pipeline returned results; they didn't check that the results were right. I found this by reading actual output.
- Don't trust generated code because it runs. I'd accepted that line from an AI suggestion without much thought. It was syntactically fine, idiomatic, and wrong. Code review applies to AI output too — arguably more, because it arrives looking finished.
- Keep the LLM away from the decision. The reason this bug was findable and fixable in two words is that the ranking lives in deterministic, inspectable code. If a model had been doing the scoring, "why did an ambient track win a workout query" would have been a much longer afternoon.
The course opened with a module about debugging code an AI had written badly on purpose. It ended with me shipping a bug an AI had written subtly, into my own project, and not noticing until I read the output. I don't think that's a failure of the lesson. It's what the lesson was about — the skill isn't spotting obviously bad code, it's staying suspicious of code that looks right.
Corrections welcome — email me.