Skip to main content
LabsAgent Notes7 min read
The second validator

The second validator

Brief: two nearly identical lines, slightly out of phase, one dimmed to a third of its glow. FLUX.2 [klein] 4B returned a perfect mirror, both copies equally bright.
PythonOpenRouter

The model can't make anything true. So what can?

Fatelock was a solo project — one player, me — and by Path E the answer was supposed to be settled. The language model could not write state, choose a scene, generate a choice, introduce a character, or decide an ending. The guardrails say it in one line:

The engine owns game truth.

I believed that. I had spent five weeks getting there. So I took the model out of the runtime, built a deterministic engine to do the job instead, and then read that engine the way an auditor would read a system they no longer trusted.

It had failed too. It has two answers to the same question.

Two ways to change the same number

The runner applies a state change in two different places, through two different engines.

When a template runs, its base_state_changes go through engine/validation.py — the old Path C validator. It enforces the granular rules I'd built up: GPA moves at most ±0.3, and only at a term boundary; body type changes one step per three turns and must be one of seven named values; fitness moves at most ±2; followers ±50,000; NPC disposition ±20; relationship state moves one step at a time.

When a choice runs, its on_click_mutations go through story/mutations.py — the new Path E engine. It does one thing: clamp the value to a per-stat bound. gpa's bound is (0.0, 4.0). There is no per-turn delta limit, no term-boundary check, no body-type enum, no cooldown.

Two deterministic paths. Same state object. Different invariants. Both are committed, both are exercised, and both write the same fields.

The old file is 578 lines and still opens with this sentence:

Per-field validation for LLM-proposed state changes. The LLM proposes state changes in its EventProposal.

The LLM stopped proposing state changes a long time ago. Nobody updated the docstring, because the file still worked, because it was still being called. It just wasn't policing the model anymore. It was policing me — the author — and I hadn't noticed the job had changed.

The same shape, twice

Two bugs made the point better than any argument. In the first, the Path E focus system — the player picks a lifestyle and gets a small passive effect each turn — wrote a body-type value that wasn't a body type at all, and raised GPA every turn with no per-turn limit and no term boundary: the exact rule the old validator enforced, routed around because the new engine didn't know what a body type was. A playtest caught it. The fix moved the body logic back into engine/validation.py, which didn't reconcile the two regimes so much as entrench them.

In the second, library_session applied what I had read as "+1 law skill" but was in fact an absolute set: {"skills.law": 1}. Under the clamp, a repeated library run decayed the skill 8 → 2. The hardest-working academic path in the game was quietly punishing you for studying. I found that one by reading the code, not by playing.

That is the shape. Both values were generated by a deterministic engine that had no notion it was writing something nothing downstream could interpret. Determinism did not save me; it guaranteed the wrong answer, consistently, until someone looked.

Correctness is not content

I did eventually build the tool that could see what tests couldn't. A seeded simulation runs the full deterministic loop across fixed strategies and reports what actually happens. The final run:

  • Routing and assembly errors: 0
  • Unfinished runs: 0

The engine is healthy. Now the rest of it:

  • Unique fragments used: 179 of 183
  • Structurally unreachable fragments: 0
  • Dormant tensions: 0 of 8 — all eight fire
  • Endings: four tones, no generic epilogue, no unknowns

That is a different game than the one the first content-health run showed, when 40 of 89 fragments appeared, three tensions never fired, and most players got the same ending. But the repair wasn't architectural. The architecture was already correct. Almost every one of those gains came from authoring: more fragments, more varied hub menus, wider gates, better economic balance, and putting the stats actually into the loops that read them.

One number for scale, since it belongs with the rest of the audit. The optional renderer — the only part of the system that costs money — runs about $0.0005 per call, roughly $0.016 for a full 32-turn playthrough, measured across rendered runs. That is roughly an order of magnitude under the < $0.20 target I'd set and, by a wide margin, the cheapest part of the project.

That's the part I keep having to relearn. Determinism bought me reproducibility, loud failure, and testability. It did not buy a story. Variety in this architecture is supposed to come from the cross product of a rich state vector and a hand-authored fragment bank — and the fragment bank had to be written by hand, one beat at a time. The bottleneck was authoring.

The old architecture is still in the code

The project's central lesson is that a declaration is not an enforcement. A 4,000-token system prompt of rules is a wish list. A sandbox described in SANDBOX.md is not a sandbox. That's why state validation moved into code, and why the guardrails now say, in as many words, that "prompting is not a guarantee."

The committed code still carries the shape of the architecture it used to be: last_choices written and read by nothing; last_beat and last_scene_id dead; fragment_beats_to_state_changes a stub that returns {} while the thesis claims fragments carry consequences; a directive field set on four of twenty-one templates; an rng_seed that is never advanced. These are fossils. Every one is a small monument to a boundary that moved and left its name behind.

The lesson was never about language models. It was about the difference between the map and the territory.

What I actually learned

The important discovery was not that deterministic architecture is better. It's narrower and more useful than that:

Generated output and canonical truth are different things — and that includes the engine's own output.

The language model produced fluent text that looked like state, and the moment that text could make something true, the game broke. So I moved truth into the engine. Then I discovered that the engine's own output — a routed scene, a mutated stat, a chosen ending, a restored old value — is also generated, and also had to be checked against something that must remain true. The picker generated a destination; it was wrong. The focus system generated a body type; nothing read it. library_session generated a state change; it was the inverse of the intent.

The question was never "who is deterministic." It's "what must remain true, and where is the single place that is allowed to make it true?" The model failed that question first. The engine failed it second. The tests failed it third.

And in the end I did not resolve it. Four validators — validation.py, mutations.py, conditions.py, graph_validator.py — and still no stated arbiter for a field two of them both claim. I never decided which mutation path should win. I chose to stop rather than keep unifying.

The model was never the hard part. Deciding what you want to be true was — and then deciding whether you want to keep going.

Next: "Two ways to bound a model."