The problem: a 500-line knowledge bank the routine was skimming
When I first built the YouTube analytics pipeline for this experiment, I structured my findings as a readable knowledge bank — docs/yt-knowhow-bank-en.md. The idea was that the LLM routine generating each day's short-form video script would read this file, absorb the findings, and adjust its output accordingly.
In practice, the routine read the knowledge bank and then ignored it.
The clearest symptom was build_in_public videos. The knowledge bank explicitly noted that build_in_public archetypes had collapsed from a median of 34 views to 8, and included a "never produce build_in_public" directive buried about 200 lines in. The routine kept producing them anyway — not every day, but often enough that the same dead format was eating publishing slots that should have gone to product_findindiegame, the format that was outperforming channel median by roughly 20× according to the view data collected by run.py.
The problem wasn't that the LLM couldn't understand the instruction. The strategic decision was embedded in a long prose document mixed with context, caveats, historical observations, and formatting notes. A 500-line file with one prohibition buried 200 lines in is not a constraint system — it is a suggestion box.
I documented the initial classifier design in a previous post and then the rebuild when the routine kept ignoring it. Neither of those fixes fully solved the root problem. This is the one that did.
The solution: a single-screen imperative committed to the repo
The fix is in scripts/yt-analytics/run.py. Each daily analytics run now writes a file at docs/yt-today-directive.md — a one-screen, imperative directive the routine must read first before generating anything:
DIRECTIVE_PATH = REPO_ROOT / "docs" / "yt-today-directive.md"
The code comment at that line reads: "the strategic decision (which archetype to make) lives here in code we control, not in the routine's prose judgment — that prose was being ignored."
The generated directive names exactly one archetype target and two prohibitions:
## SHORT (daily)
- **archetype = product_findindiegame** ← generate this today (final pick)
- open with the **numeric** hook pattern
- do NOT generate archetype: **build_in_public** (worst performer)
- **Never produce: build_in_public / meta / curated / technical one-offs**
The routine is instructed to read this file before any other context. Because it is a short file with a single actionable decision and no surrounding prose, there is nothing to skim past.
The knowledge bank still exists and accumulates observations and historical data. The directive is separate. The Python analytics script owns the decision; the LLM routine executes it.
Implementation: DEAD_ARCHETYPES and the shared bias function
Two places in run.py need to agree about which archetypes are viable: the knowledge bank section (appended to yt-knowhow-bank-en.md) and the directive (written to yt-today-directive.md). To prevent them contradicting each other, both call the same archetype_bias() function:
DEAD_ARCHETYPES = frozenset({"build_in_public", "meta", "curated", "technical"})
def archetype_bias(videos, high):
"""Compute (pref_arch, avoid_arch, pref_hook, ranked)."""
arch_views = defaultdict(list)
for v in videos:
a = v.get("_archetype", "unknown")
if a == "unknown":
continue # unclassified bucket, not a real content type
arch_views[a].append(int(v["statistics"].get("viewCount", 0)))
ranked = sorted(
((a, statistics.median(vs), len(vs)) for a, vs in arch_views.items()),
key=lambda x: (x[1], x[2]),
reverse=True,
)
...
The "unknown" exclusion took a while to get right. Early versions of the script were flagging "unknown" as either the best or worst archetype depending on which side of the distribution unclassified videos landed on. When yt-publish failures leave recent videos with no uploaded-metadata file to match against, a batch of failed uploads makes "unknown" look like the week's top performer. Excluding it means the ranking only reflects archetypes I can actually control.
The DEAD_ARCHETYPES frozenset is intentionally separate from the performance ranking. These archetypes are not just low performers — they are categorically off the table based on empirical evidence: build_in_public dropped from ~34 median views to ~8. meta, curated, and technical produced no repeat signal worth pursuing. Keeping them in a named constant means the directive can never accidentally name one as today's target, regardless of how the live ranking shakes out.
| Approach | Risk | Outcome |
|---|---|---|
| Prose prohibition in knowledge bank | LLM skims or loses context mid-document | Dead archetypes keep appearing |
DEAD_ARCHETYPES frozenset in code |
Checked before writing the directive | Never reaches the routine as a valid pick |
Shared archetype_bias() function |
Single source of truth for KB + directive | Both always agree on rankings |
The 3-in-a-row guard
Once the directive started reliably naming the winning archetype, every day became product_findindiegame day. That is fine from a performance standpoint — the proven winner should be leaned into — but it creates two practical problems: the same audience sees the same format daily without variation, and the matchup queue (which pairs specific game titles) runs down faster than it can be replenished.
The guard checks the two most recent uploads:
recent = recent_uploaded_archetypes(2)
if len(recent) == 2 and recent[0] == recent[1] == target:
alt = next(
(a for a, _m, _n in ranked
if a != target and a not in DEAD_ARCHETYPES and a != avoid_arch),
None,
)
if alt:
switched_from, target = target, alt
The key constraint: the fallback alt must not be a dead archetype. This was the source of a concrete bug on 2026-07-07 where the directive named build_in_public as today's target while the same file said "Never produce: build_in_public." The guard was correctly detecting a streak and switching off, but it was allowed to switch into a dead archetype. The fix: only accept alternatives that are neither in DEAD_ARCHETYPES nor the flagged avoid_arch.
There is also a final safety check after the guard:
if target in DEAD_ARCHETYPES:
target = DEFAULT_TARGET_ARCHETYPE
switched_from = None
Defense in depth. If the ranking data somehow produces a dead archetype as the winner before the guard runs, this catches it. The guard and the final check are independent — neither assumes the other is sufficient.
One thing the guard does not do: switch back to the winner on the next day. The routine tracks the last two uploads, not whether a switch already happened. That means the alternative archetype runs only once before the winner can resume. I considered tracking a longer history but two consecutive uploads was enough signal to break the monotony without over-correcting.
The DEFAULT_TARGET_ARCHETYPE fallback
Early in the channel's life there were not enough classified videos to rank archetypes reliably. A routing function that falls back to "no data, no decision" is useless.
DEFAULT_TARGET_ARCHETYPE = "product_findindiegame"
This is used when pref_arch == "—" (no classified data yet) and as the final safety net after the dead-archetype check. It is not arbitrary — it is the empirically known winner, set explicitly in code rather than inferred from insufficient data, because early inference was unreliable.
The full directive flow:
fetch YouTube stats
↓
attach_archetype() — match by title word overlap (≥4 words)
↓
classify() — HIGH (≥1.5× median), LOW (≤0.6× + ≥72h old)
↓
archetype_bias() — rank by median views, exclude "unknown"
↓
apply 3-in-a-row guard (no dead archetypes as alt)
↓
apply DEAD_ARCHETYPES final safety check
↓
render_directive() → write + commit docs/yt-today-directive.md
The directive is committed to the repo automatically by yt-analytics.yml. By the time the video generation routine runs later in the day, the directive is already in main waiting for it.
What I'd do differently
Emit the directive as structured JSON, not markdown. The current format is readable by both humans and LLMs but awkward for anything that needs to parse it programmatically. A JSON schema — { "target": "product_findindiegame", "hook": "numeric", "avoid": ["build_in_public"] } — would let other scripts validate the directive and alert on suspect states. The markdown format was the path of least resistance; it should be replaced before adding consumers beyond the LLM routine.
Watch time instead of view count. The classifier uses viewCount because the YouTube Data API v3 videos.list endpoint returns it without additional authentication. Watch time (minutes watched) is a stronger signal for audience retention by archetype. The YouTube Analytics API with OAuth would unlock this — it is a separate API surface with different quota units. I have not wired it up yet; the current classifier is good enough to be directionally correct.
Extract and unit-test the 3-in-a-row guard. The guard logic is embedded in render_directive(), which requires mocking the full analytics pipeline to test. Extracting apply_streak_guard(target, ranked, recent, dead) as a standalone function would let me test it with a fake history — including the edge case where all non-dead archetypes are also the avoid_arch (currently falls through to keeping the winner, which is correct, but that path is untested).
The 48-Shorts retrospective documents the empirical data behind these archetype decisions. The longform pipeline queue logic and the two-host spec format are downstream consumers of the same archetype system. The quota and cron work covers the GitHub Actions scheduling side — the analytics workflow and the generation workflow run at different times by design.
FAQ
Why not embed the prohibition in the system prompt?
The system prompt is shared across multiple routines. Embedding archetype-specific prohibitions there requires a prompt change whenever the winning format shifts. The directive file lets the analytics script update the decision without touching the prompt.
How does the routine know to read the directive first?
The routine's task description lists reading docs/yt-today-directive.md as the explicit first step, before any other context. Position in the task description matters — front-loading the constraint makes it harder to skip past.
What happens if the YouTube API is unavailable?
The analytics script fails gracefully — it writes a minimal report noting insufficient data and does not update the directive. The previous day's directive stays in place. A transient API failure does not reset the routine to a default it never consciously chose.
Doesn't hardcoding DEAD_ARCHETYPES create a maintenance burden?
The frozenset has changed twice in six weeks. That is a lower rate than prose documents, which tend to accumulate qualifications until the original prohibition is diluted. Code-owned constants are easier to audit — grep -r DEAD_ARCHETYPES shows every place they are used and enforced.
Why frozenset instead of list or set?
Membership testing (target in DEAD_ARCHETYPES) is O(1) for frozenset vs O(n) for list. With four elements the runtime difference is negligible, but frozenset also prevents accidental mutation mid-run. A set would allow .add() to silently change the constraint during execution.
Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.
Top comments (0)