DEV Community

Cover image for Building "The One-Sentence Torture Test": making LLM comparisons falsifiable
Harish Kotra (he/him)
Harish Kotra (he/him)

Posted on AI-assisted

Building "The One-Sentence Torture Test": making LLM comparisons falsifiable

How a deliberately useless constraint ladder became a real measurement tool — and the six bugs that taught me the most.

The problem with "which model is better?"

Every model comparison I've read lately ends the same way: two paragraphs side by side, and a judgement call. The longer answer "feels" smarter. The one with bullet points "feels" more organised. Nobody can point at a number and say this is why.

The trouble is that most interesting LLM tasks don't have a correct answer. Summarise this article. Explain this concept. Write a function. You can grade them, but the grader is another model, and now you're measuring your judge.

So I built something with a different property: a task where the answer is either right or wrong, and a regex can prove which.

The premise

Give the app a concept — recursion, love, a for loop. It asks two models to explain it under seven escalating formatting constraints:

# Constraint Checkable by
1 Exactly one sentence counting terminators
2 Exactly 10 words split(/\s+/)
3 Exactly 5 words split(/\s+/)
4 As a pirate lexicon membership
5 Only the characters 0 and 1 /^[01\s]+$/
6 As a haiku (5-7-5) syllable estimate
7 One sentence, no letter e case-insensitive scan

These constraints are useless. That's the point. Nobody needs recursion explained in pirate speak. But "exactly 5 words" is not a matter of opinion, and that makes the whole thing falsifiable.

The failure messages are the product:

 2 ten-words   A PASS  10 / 10 words
 2 ten-words   B FAIL  11 / 10 words
 5 binary      A FAIL  empty output      rt=3200
 7 no-e        B FAIL  3 / 0 'e' characters
Enter fullscreen mode Exit fullscreen mode

You can argue with 11 / 10 words. You can't argue with a vibe.


Architecture: one module, two consumers

The single most important decision in this codebase is that the ladder lives in shared/ and is imported by both the server and the browser.

Architecture

The client imports server-side code over a relative .ts path, which TypeScript allows with allowImportingTsExtensions: true. That means:

  • The UI renders the rungs from the same definitions the server enforces.
  • The word count shown in the browser is computed by the same function that produced the verdict.
  • It is structurally impossible to ship a UI that promises a constraint the server doesn't check.

The alternative — duplicating the constraint list in the client — is the kind of bug that doesn't show up until someone changes one side.

Each rung is data plus a pure function:

export interface Rung {
  id: RungId;
  index: number;
  label: string;
  instruction: string;       // the exact text sent to the model
  spec: RungSpec;            // declarative rule
  heuristic: boolean;        // does the verdict rely on an estimate?
  validate: (raw: string) => RungCheck;
}
Enter fullscreen mode Exit fullscreen mode

RungCheck carries a machine-readable actual / expected / unit triple, which is what lets the UI render "11 / 10 words" without the UI knowing anything about word counting.


Design decisions that mattered

Never trust the model to report on itself

The model is asked to produce text, never to describe it. Every number is recomputed from the raw string. If a model says "here is exactly 10 words" and emits 11, the verdict is 11 / 10 words.

This sounds obvious. It's the reason the tool is worth building.

Empty output is a failure everywhere

An empty string trivially satisfies "contains no letter e". It also satisfies "no forbidden characters". Every rung explicitly rejects it, and the verification harness asserts that all seven do:

record(
  'every rung rejects empty output',
  LADDER.every((r) => r.validate('').ok === false),
  `${LADDER.length}/${LADDER.length} rungs reject ""`,
);
Enter fullscreen mode Exit fullscreen mode

Without this, the cheapest strategy for a struggling model is to say nothing.

Reasoning content is never read

Reasoning models return a reasoning_content field. I made a rule: read the token count,
never the text.

export function scrubReasoningContent<T>(value: T): T {
  if (Array.isArray(value)) return value.map(scrubReasoningContent) as unknown as T;
  if (value && typeof value === 'object') {
    const out: Record<string, unknown> = {};
    for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
      if (/reasoning_content|reasoning_text|thinking/i.test(k)) continue;
      out[k] = scrubReasoningContent(v);
    }
    return out as unknown as T;
  }
  return value;
}
Enter fullscreen mode Exit fullscreen mode

The scrubber runs before every write to disk, so hidden chain-of-thought can't leak into the log even if a provider returns it. The verification harness then re-reads the raw file and scans every line to confirm — 280 lines, no reasoning_content.

I added this partly on principle and partly because reasoning traces are the most likely place for something sensitive to end up in a JSONL file you forgot about.

SSE over POST

EventSource only does GET, and the concept plus the full provider config (including API keys) must not go in a query string. So the client uses plain fetch, reads response.body as a stream, and hand-parses the frames:

const res = await fetch('/api/torture', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ concept, config }),
});
const reader = res.body.getReader();
// decode chunks → split on "\n\n" → parse each `data:` line as JSON
Enter fullscreen mode Exit fullscreen mode

Keys stay out of URLs and out of access logs, and verdicts still arrive as they land.

Per-provider reasoning toggle

chat_template_kwargs: { enable_thinking: false } is a vLLM/SGLang extension. It is not in the OpenAI spec. A strict server may reject unknown fields outright.

I originally had it as a global checkbox. When I added multi-provider support, I made it per-endpoint, because a global toggle would break a user's custom server the moment someone flipped it for the hosted one.


The bugs that taught me the most

This is the part worth reading.

1. The SSE stream produced nothing at all

The endpoint looked correct and returned HTTP 200 with the right headers. No events arrived.

The cause: I had wired the abort handler to req.on('close'). With express.json() in the middleware chain, the request stream is fully consumed by the body parser, and 'close' fires on the request almost immediately — so my handler tore down the response before the
first frame was written.

The fix was to listen on the response:

res.on('close', () => { /* client actually went away */ });
Enter fullscreen mode Exit fullscreen mode

One word. Two hours. If you build SSE on top of a body parser, this is the bug you will hit.

2. verify.ts silently booted a second server

The verification harness imported ./index to reuse scoreRun(), which executed the module's top-level app.listen(). The harness was then talking to a second server instance while I watched logs from the first, and the evidence didn't match what I thought I was testing.

The fix was to extract the scoring logic into scoring.ts so the harness could import pure functions without side effects. Importing a module should not start a server. If your module has top-level side effects, your tests will eventually lie to you.

3. Rung 7 only enforced half its instruction

The instruction says: "exactly one sentence that contains no letter e."

My validator only checked the e. A model could emit five sentences with no e and pass.
The verification harness caught it because the prompt and the check disagreed — a good reason to keep the instruction and the validator in the same object, where you can see the mismatch.

4. Empty output reported as a charset violation

When a model returned nothing, rung 5 reported charset violation: followed by an empty list of offenders. Technically true, completely useless. Empty output is now detected first and reported as empty output.

Good failure messages are a feature. charset violation: with nothing after it is a bug report about your own error handling.

5. Duplicate React keys

Two model slots pointing at the same provider produced identical validation strings, so React logged duplicate-key warnings. The fix was deduping the problem list — which also improved the UX, since showing the same error twice is noise.

Small, but it's the kind of thing that only surfaces when you actually look at console output.

6. The one that matters: 49/49 green while everything was broken

This is the bug I'd frame.

My browser test suite reported 49/49 passing. In the same run, the live ladder had failed all 14 rungs. The scoreboard read 0 PASS, 14 FAIL.

The cause was in my test, not the app: a selector meant to fill the API key field was filling the Base URL field. The app dutifully tried to POST to an endpoint named after an API key, every call failed, and the UI correctly rendered 14 failures.

The suite passed because it was checking the wrong things. It verified that the settings panel opened, that fields persisted, that the heuristic badge appeared — and it never once asserted that a rung passed.

Two lessons:

  1. A green test suite is a claim, not evidence. Assert on outcomes, not on the absence of exceptions. If your integration test can pass while the core feature fails completely, it is measuring the wrong thing.
  2. Read the output, not just the exit code. The 0 PASS, 14 FAIL line was right there in the scrollback, three lines above 49/49 browser checks passed. The signal was present; I had to notice it.

I now have the harness print the actual verdict distribution, and I check it.


Verification in three layers

Because the whole project is a claim about correctness, I built the evidence to match.

Layer 1 — unit tests (49). Validator tests are written to prove validators can fail: known-bad inputs asserted to produce specific verdicts. A validator with no failing test isn't done.

Layer 2 — live verification (15 checks, 16 cross-provider). Runs the real ladder against real providers and asserts things like:

[PASS] known 12-word string vs 10-word limit
       counted 12 words -> verdict "12 / 10 words" (ok=false)
[PASS] AC3 — both models received byte-identical prompts on every rung
       7 rungs x 2 models: all prompt strings identical
[PASS] AC5 — reasoning_content never stored (scanned the raw JSONL on disk)
       280 JSONL lines scanned, no reasoning_content field
[PASS] AC1 — word-count verdicts equal an independent recount of the raw output
       ten-words/B: validator=11, recount=11
Enter fullscreen mode Exit fullscreen mode

Note the shape: each check asserts a property, not a snapshot. "The validator can fail" is a property. "Byte-identical prompts" is a property. These don't go stale.

Layer 3 — real browser (49 checks). Drives Chrome against the running UI, and includes a dependency-free PNG decoder so it can read the rendered pixels and confirm the three.js scene actually drew green and red rungs — not just that the DOM contains the right text.

It also runs a product-surface leak guard that fails the build if an absolute filesystem path, .jsonl, a host:port string, or a raw API field name ever appears in the UI. Users should never see my data/runs.jsonl path; they should see a download button.


Proving multi-provider actually works

Supporting "any OpenAI-compatible endpoint" is easy to claim and easy to get subtly wrong — particularly key handling. The failure mode I cared about: leaking provider A's key to provider B.

So I wrote a mock provider that rejects any bearer token except its own, pointed Model A at Particle and Model B at the mock, and ran the ladder:

[PASS] CROSS-PROVIDER: Model A and Model B hit different endpoints
       A -> https://api.particle.ai/v1 | B -> http://127.0.0.1:8099/v1
[PASS] every result records which provider served it
Enter fullscreen mode Exit fullscreen mode

Had the app sent the wrong key, every call to B would have 401'd. The latency in the results makes the split unmistakable — 8ms from the local mock, ~1,400ms from Particle — while the validators ran identically on both outputs.

This is the cheapest possible way to test a routing claim: build the hostile counterparty first.


What the torture test actually reveals

Running the default pair on recursion produces a real, reproducible finding: the binary rung is the killer.

Both models tend to spend their entire token budget on hidden reasoning and emit nothing at all:

 5 binary   A FAIL  empty output   rt=3200  9515ms
Enter fullscreen mode Exit fullscreen mode

rt=3200 is the reasoning token count. The model thought for 3,200 tokens and produced zero characters. My retry-with-doubled-budget path fired and also came back empty.

Turn off reasoning and the failure mode flips completely. Binary passes easily, but the tight word-count rungs start failing on genuine over/under-shoots:

 2 ten-words  B FAIL  11 / 10 words
 3 five-words B FAIL  4 / 5 words
 7 no-e       B FAIL  3 / 0 'e' characters
Enter fullscreen mode Exit fullscreen mode

Same models, same prompts, entirely different failure surface. That's the interesting result:
the constraint that breaks a model tells you something about how it fails, not just whether it's "better."

An honest caveat: the lead changes hands between runs. I report what the verdicts say and don't tune for a preferred winner. One run is an anecdote. The value is the mechanism, not one scoreboard.


If you want to build something like this

Make the answer checkable before you make it interesting. I spent the first hour on the ladder, not the UI. If you can't write a pure function that returns true/false plus a readable reason, you don't have a measurement yet.

Put the rules where both sides can see them. One module, imported by server and client.
The class of bug it eliminates is large.

Write validators that fail on purpose. Every constraint in this repo has a test proving it rejects a known-bad input. Otherwise you're testing that your code runs, not that it works.

Assert properties, not snapshots. "Both models got identical prompts" survives a refactor. A golden-file comparison does not.

Report empty output as its own thing. It's the most common real failure with reasoning models, and it looks like a dozen different bugs if you don't name it.

Read your test output. 49/49 green while the app failed every single rung.


Try it

npm run install:all
npm run dev          # API :3001, UI :5173
Enter fullscreen mode Exit fullscreen mode

Paste any OpenAI-compatible key in Settings, pick a concept, run the ladder. Or point Model B at your own local server and race a hosted model against it.

The repository includes the full verification suite, the mock provider, and the raw JSONL evidence behind every number in this post.

Code & more: https://www.dailybuild.xyz/project/259-one-sentence-torture-test

Top comments (0)