DEV Community

Cover image for Nothing Reported It
Ken W Alger
Ken W Alger Subscriber

Posted on Originally published at kenwalger.com

Nothing Reported It

Silent prompt failure yields plausible fake wine

Five ways my own test harness lied to me, and the one that nearly got published

The number that gave it away was sitting in a table I had already read three times.

prompt size    97 tokens median, 112 max
Enter fullscreen mode Exit fullscreen mode

That is the grounded question-answering workflow. It sends four reference documents with every question. The documents are 447 words.

Four hundred and forty-seven words cannot be ninety-seven tokens.

I had written that number into a results document as good news about context headroom. "Prompt sizes never approached the 4096-token context window." It was not good news about headroom. It was the harness telling me, in the only field capable of telling me, that the reference material was not in the prompt. The directory holding it had never been committed to the repository. Every one of those fifteen questions had been asked against an empty reference block.

Both models had behaved impeccably. One had been instructed to answer NOT IN THE MATERIAL when the material did not contain the answer, and the material contained nothing, so it said NOT IN THE MATERIAL thirteen times out of fifteen. The other had no such instruction, so it answered from whatever it had memorized during training and invented a wine.

Every call returned 200. The scorer scored it. The scores were internally consistent, had plausible and different failure modes on each side, and I wrote two thousand seven hundred words about what they revealed regarding the capabilities of small language models.

They revealed nothing about small language models. I had measured what happens when you ask a question with no reference material, which is a question I was not asking and which has an uninteresting answer.

This is the post about that, and about the four other things I found in the same weekend, because the missing directory was not the first one and it was not even the most instructive.

The experiment, briefly

A Raspberry Pi 4, two small models through Ollama, and three workflows: pull an identifier out of a message, route a message to one of six queues, answer a question from four documents. Ninety-five test cases. A deterministic baseline for the first workflow. Predictions committed before anything was installed.

The findings are a separate post. This one is about the code I wrote to produce them, which turned out to be where the actual bugs were.

Five flaws. Every single one was found by reading output, not by reading code. I never once found one of these by looking at the scorer.

Kind one: a metric that rewards the safe failure

This one I hit twice, in two different workflows, and did not recognize the second instance as the same bug until I had already fixed the first.

The classification scorer. Six labels, one of which is escalate. The instruction said to escalate when a message needs a person, including when you cannot tell what it is about. So I built a metric called confident wrong: wrong, and not an escalation. The reasoning is sound. A model that escalates when unsure is usable. A model that routes a trademark notice to billing is not.

Then the 360M model answered escalate to 43 of 50 messages.

It scored 3 on confident wrong. The 1.5B model, which was genuinely attempting the task and getting 46% of it right, scored 26.

My metric had just certified the useless model as roughly six times safer than the mediocre one. Because escalations are exempt, a model that escalates always cannot register a confident error. The exemption I added to reward caution had instead rewarded paralysis, and it did so in the direction that looks best.

The grounded QA scorer. Four of the fifteen questions have no answer in the corpus. Correctly saying so is the behavior that matters most, because a system that invents a plausible answer with no evidence is worse than one that fails loudly: it is indistinguishable from one that worked. So the headline number was correct abstentions out of four.

Then the 1.5B model said NOT IN THE MATERIAL to 13 of 15 questions.

It scored 4 out of 4. A perfect score, on the metric I cared most about, for a model that had answered almost nothing.

Same bug. Different workflow. Two hours apart. I fixed the classification one, wrote a comment in the code explaining the principle, and then failed to apply that principle to the file I opened next.

The shape, stated once: a metric that rewards the safe behavior will certify any model that only ever does the safe thing. If you exempt a behavior because it is cautious, you have to pair that exemption with evidence that the model is making distinctions at all. Otherwise, the exemption is the score.

Both fixes are the same three lines: count how concentrated the answers are, and when one answer dominates, print a warning that the headline number is meaningless and say why.

Kind two: a metric that measures nothing at all

The vacuous fixture. I caught this one before the run, which is the only reason it is not a bigger part of this post. A test case that passed regardless of what the model did, because the fixture was inert either way. It contributed a tick to a column and no information.

The keyword check, which is worse, because it survived longer and looked more useful.

The eleven answerable QA questions each carry a list of words the correct answer should contain. The scorer checks whether any of them appear anywhere in the reply. I labeled it "indicative only" and moved on.

On the invalid run it scored 1 of 11 for both models. The single hit was the word "reservation", appearing inside this answer:

You owe a tasting fee of $100 for the weekend. You also need to book a reservation for the weekend.

The correct fee is forty dollars. The hundred is invented. That one hit was the entire positive signal in my automated QA scoring and it was noise.

Here is the part that makes it a better story. After I fixed the corpus and reran, the keyword check scored 11 of 11 for the 1.5B model. Vindicated, apparently. I read all eleven answers by hand anyway, and nine of them are right.

So the check was wrong both times. It was wrong in the pessimistic direction when the answers were bad and wrong in the optimistic direction when they were good, and on the rerun one of its eleven hits is an answer that gets the arithmetic wrong and hits on the word "reservation" anyway.

A check that agrees with the truth because the truth happened to be agreeable has not been validated. It has been lucky.

Kind three: a detector with a blind spot in the obvious direction

I wrote a decay check because of a thermal problem. A Pi 4 with heat sinks and no fan hit its soft temperature limit during a thirty-call run and started capping the clock, and nothing anywhere said so. So the scorer now compares generation speed in the first third of a run to the last third and flags a slowdown.

It then reported this, as steady:

across the run 4.3 -> 7.6 tok/s (176% of starting speed, steady)
Enter fullscreen mode Exit fullscreen mode

The hardware got seventy-six percent faster partway through a run. My detector, which was looking for numbers below a threshold, saw a number above it and reported no problem.

Hardware does not speed up. That reading is either contention at the start of the run or, as it turned out, generations too short to time. Either way it is a signal that the measurement is unreliable, and I had built a detector that would only ever notice half of the ways that can happen.

Anything you threshold in one direction, threshold in both. An implausibly good number is as informative as an implausibly bad one and nobody ever writes the check for it.

Kind four: errors that get dropped instead of counted

Partway through the first extraction run, Ollama returned a 500 and the runner died at case seven of thirty. The traceback was useless because urllib raises HTTPError and discards the response body, which is where Ollama puts its reason. Reading e.read() gave it up immediately:

{"error":"prediction aborted, token repeat limit reached"}
Enter fullscreen mode Exit fullscreen mode

The 360M model, asked to find an order number in "Thanks for a lovely afternoon yesterday," where the correct answer is one word, had fallen into a repetition loop and been killed after twenty-one seconds. Reproducible at temperature zero. The input causes it.

My first fix was to record failures and keep going, which was right. My second was to exclude all failures from the scores as infrastructure noise, which was badly wrong.

That call was not infrastructure noise. The model ran. It received the prompt, generated tokens, and produced nothing usable. That is a result, and excluding it would have dropped from the denominator the single case this model demonstrably could not handle, scoring it only on the cases it survived.

So the runner now tags every failure as one of two kinds:

  • Model failure. The call reached a working model and the model failed. Stays in the denominator. Counts against the model. Reported on its own line as "no usable answer."
  • Transport failure. The call never reached a working model. Ollama was down, the tag was wrong, the request timed out. Nothing was measured, so it is excluded and the run is reported as incomplete.

The distinction is a five-line change and it is the difference between measuring a model and measuring a model on the subset of inputs it happens to tolerate.

Kind five: inputs that go missing without raising

This is the missing corpus from the top of the post, and it is a different and worse category than everything above it.

The function was four lines:

def corpus_text():
    parts = []
    for p in sorted((FIX / "corpus").glob("*.md")):
        parts.append(p.read_text(encoding="utf-8").strip())
    return "\n\n".join(parts)
Enter fullscreen mode Exit fullscreen mode

A glob that matches nothing yields nothing. An empty list joins to the empty string. No exception, no warning, no exit code. It returns a perfectly valid value that happens to be the absence of the thing it was asked for.

And on the same evening, a sibling. The offline test's entire product is a text transcript. I copied it into the run directory, committed, pushed, and it was not there. The standard Python .gitignore template, which I had pulled in wholesale, carries *.log on line 165, and a pattern without a leading slash matches at any depth.

The copy succeeded. git status came back clean. The push reported success. The one artifact the test exists to produce was gone, and everything in the chain said fine.

Absence does not raise. A missing directory, a missing file and an inert fixture all present as an ordinary successful run. That is narrower and more useful than "test your tests," and it generalizes: every input a harness depends on needs a loader that refuses to proceed without it, and every artifact it produces needs to be verified as present rather than inferred from an exit status.

The loader now looks like this, and the comment is longer than the code because the comment is the part I will need next year:

paths = sorted(corpus_dir.glob("*.md"))
if not paths:
    raise SystemExit(
        f"No corpus documents found in {corpus_dir}\n"
        "Grounded QA cannot run without reference material, and running\n"
        "it against an empty reference block produces numbers that look\n"
        "like a result."
    )
Enter fullscreen mode Exit fullscreen mode

It also refuses a corpus implausibly smaller than the real one, the run prints the document count and token estimate before its first call, and the scorer independently flags any QA run whose median prompt is under 400 tokens. Three checks for one bug, in the input, in the run, and in the output, because this is the bug that cost a day.

The detector that fired, and was right, and told me the wrong thing

Here is the part I find hardest to write and most worth keeping.

The abstention-collapse check from kind one did fire on the invalid run. It printed exactly this:

!! ABSTAINED on 13/15 questions overall. It is refusing, not judging.
   The abstention score below is not evidence of discrimination:
   a model that never answers gets it right for free.
Enter fullscreen mode Exit fullscreen mode

It was correct. Something was badly wrong with that run.

Its diagnosis was completely false. The model was not refusing. It was obeying, precisely, an instruction to report when the material lacks the answer, in a situation where the material lacked everything. The fault was not in the model's judgment, and it was not in the metric. It was in a directory that did not exist, two layers upstream.

And I acted on the diagnosis. I wrote it up: the model has a stuck needle, its perfect abstention score is an artifact of refusal, here are the implications for anyone building on a 1.5B model. Confident, specific, sourced to real output, and wrong in every particular.

A flag that tells you something is off is not a flag that tells you what. The check could only see the output. The fault was in the input. It fired for the right reason and pointed in the wrong direction, and because it fired I felt more confident in a conclusion that was false, not less.

That is the failure mode I had no defense against and still only partly do. A monitoring system that is right about the existence of a problem and wrong about its cause is more dangerous than no monitoring, because it terminates the search.

What I actually changed

For anyone who wants the diffs rather than the moral:

  • Collapse checks on both classification and QA: when one answer exceeds half the set, print the concentration and declare the headline metric meaningless for that model.
  • The decay check flags implausible speedups as well as slowdowns, and notes when generations are too short to time.
  • Failures split into model and transport, with model failures staying in the denominator.
  • corpus_text() raises on an empty or implausibly small corpus.
  • The QA run prints its corpus size before the first call.
  • The scorer flags a QA run whose prompts are too small to contain the corpus.
  • The keyword check is relabelled as a weak lower bound, in the output, in the README, and in the results.
  • .gitignore no longer lets *.log swallow run transcripts.
  • vcgencmd get_throttled before and after every run, recorded in the run document rather than checked and forgotten. The reading reports bits set since boot, so it is perishable: power-cycle the board and the evidence is gone.

The uncomfortable part

Every one of these was found by looking at data. Not one was found by reading code, reviewing the scorer, or writing a unit test for the harness.

The vacuous fixture turned up while hand-checking a passing case. The confident-wrong flaw turned up because a score of 3 against 26 looked backwards. The abstention flaw turned up because 4 out of 4 looked too good. The keyword flaw turned up because 1 of 11 looked too bad, and I read the one. The missing corpus turned up because I was double-checking a latency figure and noticed a prompt that was too small to be possible.

Which means the only reason I found them is that I kept pulling threads after I had numbers that looked publishable. The version of this project that shipped on schedule shipped on Tuesday, with a clean verdict table, a confident conclusion about small models and grounded question answering, and a repository anyone could clone to reproduce the exact same wrong result.

Every number in it would have been reproducible. That is the thing I keep turning over. Reproducibility is table stakes and it would not have saved me, because the harness reproduced its own bug faithfully every single time.

I do not have a process that catches this reliably. What I have is a weaker and more honest claim: the checks I added are each a direct response to one specific thing that actually happened, which is the only kind of check I trust, and the number of them that I thought up in advance is zero.

So the two rows I am adding to my prediction template, to be filled in before anything runs:

  • What would have to be true for these numbers to be meaningless?
  • Would anything in the harness tell me if it were?

Twenty-two predictions about what the models would do. Not one about what the measuring code would do. Four of the five flaws were in the measuring code.


The harness, the fixtures, the committed predictions and the full run record, including the invalid run and what it produced, are at
github.com/kenwalger/right-size-the-model.
The findings the harness eventually produced are in the companion post.

Top comments (11)

Collapse
 
arhancanli profile image
Arhan Canli •

The 4.3 -> 7.6 tok/s reading may be a cold start rather than contention. If the speed is computed from wall-clock time, the first generations include Ollama's model load, and with short outputs that dominates. The response splits it out: load_duration, prompt_eval_duration, and eval_count / eval_duration (nanoseconds), so tokens per second from eval_duration alone leaves load and prompt processing out. Worth keeping the first call out of the "first third", or reporting it on its own.

One more on the 13 of 15 NOT IN THE MATERIAL: with n=15 the Wilson 95% interval for 13/15 is about 62% to 96%, and 4 of 4 abstentions has a lower bound near 51%. Printing the interval next to the headline would carry the same warning your concentration check does. Does the decay check read eval_duration, or time around the request?

Collapse
 
kenwalger profile image
Ken W Alger •

Good catches, and checking them properly turned up something worse than either of us suggested.

First, directly: the decay check reads eval_duration, not wall clock. tps is eval_count / eval_duration, and load_duration only feeds a separate first_token_ms field the check never reads. So cold start can't explain it. The load was real and enormous: 96.7 seconds on the first QA call, down to a 2.8-second median afterward, but it never showed up in that number.

You were right that short outputs break this, just by a different mechanism. Reported tok/s fits n / ((n-1) × t) to within 2% across my runs. That's consistent with eval_count counting n tokens while eval_duration covers approximately n-1 inter-token intervals. The resulting inflation is n/(n-1): 2x at two tokens, 1.12x at nine.

In the extraction run, the answers drop from nine tokens to two partway through, which explains the entire apparent increase from 4.3 to 7.6 tok/s. Inter-token time is flat. Nothing got faster.

So the metric I built specifically so that answer length wouldn't contaminate the speed reading is contaminated by answer length.

Your interval arithmetic is right too: 62.1% to 96.3% for 13/15, and a 51.0% lower bound for 4/4. These are hand-built fixtures rather than a random sample, so those intervals illustrate the uncertainty in extrapolating from small counts, not a statistically justified bound on general capability.

Thanks. This is the second measurement error in this project that someone other than me found.

Collapse
 
mrsaynothing profile image
Mr Say Nothing •

The 97-median-token line is the whole post in one number: the harness reported the empty reference block correctly, three reads in a row, and it got past you because the scores came back internally consistent with plausible, different failure modes on each side — which is precisely what a successful-looking run is supposed to feel like. Every call returned 200, the scorer scored, 2,700 words of conclusions got written, and the entire edifice measured a question nobody was asking. Worth saying the wine-inventing model was not malfunctioning either: it answered the prompt it actually received, and that prompt contained nothing. The only number telling the truth was in a table you had already read — telemetry stays honest, humans skim. That asymmetry is the reusable part.

Collapse
 
kenwalger profile image
Ken W Alger •

That's an excellent reading of what went wrong. The measurements were internally consistent, the model behaviors looked meaningfully different, and the whole thing was plausible enough that I went on to draw conclusions about an experiment I hadn't actually run as intended.

I'd qualify two parts slightly. The model that invented a wine was responding without the intended reference material, but whether inventing an answer was acceptable still depends on the instructions it received. And telemetry can be accurate about its observations without being sufficient to support the interpretation we place on them.

In fact, a later reader helped uncover another measurement problem in the throughput calculation, so even the instrumentation deserved more scrutiny.

But I completely agree with your central point: the empty reference block was visible in the results, and I read past it because the rest of the experiment seemed coherent.

That's probably the most reusable failure in the entire project. The system didn't need to hide the warning. It only needed to produce enough plausible surrounding evidence that I failed to recognize its significance.

Thanks for articulating that so well.

Collapse
 
ivannovazzi profile image
Ivan Annovazzi •

Oof, the invented wine got me. One model said NOT IN THE MATERIAL thirteen times while the other just made up a bottle, and every single call returned 200 through all of it. If you haven't already, worth logging the full prompt and asserting a sentinel string from the docs shows up in it, that catches this class of bug dead

Collapse
 
kenwalger profile image
Ken W Alger •

Exactly. The HTTP 200s say nothing about whether the model received the material it needed to answer correctly.

I like the sentinel assertion as a regression test for that specific failure mode. It would catch the case where the expected source material never made it into the assembled prompt.

The qualification I'd add is that one sentinel doesn't prove the rest of the context arrived intact or that the answer was grounded in it. It's evidence that one boundary was crossed successfully, not proof of the entire chain.

And that's part of what made the invented bottle so revealing: the system could be operationally healthy while its evidentiary basis was missing. The model that repeatedly answered NOT IN THE MATERIAL was arguably behaving much more responsibly than the one that confidently invented an answer.

Collapse
 
anp2network profile image
ANP2 Network •

Your second failure mode has a version that never gets caught before the run, because it survives the entire history looking healthy, and the worst place for it to live is a score. A read-only scan today over the append-only event log we operate for AI agents pulled all 1,783 task-verdict rows. The numeric score field takes two values. 1.0 on 1,745 rows, 0.0 on 37. Those two sets are exactly the pass set and the fail set. So a float that reads like a graded quality measure is a re-encoding of the boolean sitting next to it, and ranking providers by mean score, which is the obvious thing to do with a float, only ranks them by pass rate.

The explanation fields complicate "absence does not raise" rather than just repeating it. Across those 1,783 rows the reasons field has five distinct values, and 1,744 of them are the same string. The evidence field, which exists to name event ids a reviewer could go re-check, is an empty array on 1,781 rows. All of those rows have the key. A presence check that tests for schema keys blesses every bit of this, and schema validation did exactly that for months, which is your kind five one layer in: verifying an artifact is present has to reach inside the container, since the key is not the content.

The thing I did not predict was the overlap. Exactly one row has non-empty evidence. Exactly one row carries itemized reasons instead of the constant. It is the same row. The decent behaviour did not spread thinly across fields, it collapsed into a single record, so the per-field numbers (99.9% empty, 99.9% constant) describe the shape badly. There is no gradient to walk up. There is one row that did the work and 1,782 that emitted a correctly shaped placeholder.

Your two new template rows are useful, but both get answered by reasoning about the harness before the run, and your own account gives that an uncomfortable record of zero for five. The data-side version is runnable after the experiment and before the write-up, without consulting what any field was meant to mean: for every column the write-up leans on, print its distribution, then measure how much of it another column in the same table already explains. Our score fails that in one line. Distribution alone would not have been enough, since two values heavily skewed toward 1.0 could plausibly mean the tasks were easy; the comparison against the pass bit is what exposes the duplication. It reaches your abstention collapse too, that being concentration in a single column, which is the check you ended up hand-building. The honest limit is that it inherits the weakness you name at the end. It tells you a column is degenerate and stays silent about why, so its output is a reason to go look upstream, never a diagnosis.

In the verdict table you already have, does any column still move once you hold the headline pass-or-fail result fixed?

Collapse
 
kartik-nvjk profile image
Kartik N V J K •

The 360M model scoring 3 on confident wrong while escalating 43 of 50 is a trap I'd walk straight into, since the number moves the way everyone wants it to. The abstention check worries me more, because it fired for the right reason and still sent you to the wrong layer. Would it help if each warning printed what it could actually see, so the QA collapse message said it had only read outputs and never the prompt?

Collapse
 
kenwalger profile image
Ken W Alger •

Yes, I think that would have made a substantial difference.

The abstention check detected a genuine pattern, but it had no visibility into the assembled prompt or retrieved reference material. It could tell me what the model returned, not why.

Having each warning declare its observation boundary would make that limitation explicit. Something like "13/15 abstentions observed; checker inspected responses only; prompt and retrieval contents not verified" would have been much harder to mistake for a diagnosis.

I particularly like that this doesn't require the checker to become more sophisticated at interpreting results. It requires it to be more precise about what it actually knows.

That's a useful design change for the harness, and it fits a larger principle I've been working through: evidence should carry its own visibility limits rather than leaving readers to infer them.

Thanks for the suggestion.

Collapse
 
friga profile image
Artur Pokładek •

Had the same thing with a contact form on a shop I took over. Green success message, messages went nowhere, and nothing ever errored. How do you catch it when the wrong number looks normal?

Some comments may only be visible to logged-in visitors. Sign in to view all comments.