← All writing

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:

ModuleWhat I builtWhat 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.

Because the ranking is deterministic, the system also has a full offline path — with no API key it parses with keyword matching and explains with templates. Anyone can clone it and get reproducible results without spending a token, and the tests pass either way. Worth being precise about what that does not mean: the two modes don't always agree. A better-parsed profile is a different input, so it can produce a different winner. The engine is deterministic; the profile feeding it isn't fixed. That fallback is also where the bug lived.

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 fixAfter
Parsed genreambient (invented)None
Top pickan ambient trackIron Verdict — metal, energy 0.97
Stated reason"genre match (+2.0)""mood match (+1.0); energy close to target (+0.93)"
Was it wrong?YesNo
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.

The same principle upstream. The Claude parse is constrained by a JSON schema whose 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.

Full source, model card, and evaluation harness: github.com/abdurahim50/applied-ai-music-recommender. Built for CodePath AI-110 — Foundations of AI Engineering.

Corrections welcome — email me.