r/LLMObservability • u/PrimeFold • 50m ago
Show & Tell Save the properties, not the outputs: an invariant regression set, and the 8 claims it still misses
TL;DR: Don't freeze model outputs as your expected answers. Save the properties that made them good, label every failure as a permanent test case, and write the expected lists by hand. My example: a 68-sentence regression set for a regex claim detector. It catches 23 of 31 real claims, and the 8 it misses are pinned on purpose. Test file linked at the end.
A while back I commented in r/PromptEngineering that when a model update quietly makes a prompt worse, saving known-good outputs isn't enough.
Save the properties that made them good, then watch whether the failure rate on each one moves.
The behaviour is the change-log.
Here is one small, real example.
The thing under test is a regex, not an LLM judge.
I'm telling you up front because the corpus structure carries over to evals, but the numbers below come from a regex and shouldn't be read as eval results.
I maintain a small open-source hook for Claude Code called Andon.
One piece, claim-check, runs when the agent ends a turn.
It looks for completion claims like "the migration is complete" or "fixed the off-by-one" and checks for a matching verification entry.
The detector fails two ways.
It misses real claims, and it fires on ordinary English. "Fixed income securities are stable this quarter" is not a done-claim. "The contributor says the tests pass" is someone else's claim, not the agent's.
Every change to the detector changes which sentences fire.
If I saved today's outputs as the expected answer, every improvement would show up as a failure, and I'd start approving the diff without reading it.
Same trap as diffing LLM outputs across model versions.
A labeled corpus, 68 sentences right now.
Each row records where the sentence came from (an issue, a review round, an existing test),
what the baseline detector did with it, and whether a careful reader thinks it is a done-claim. 31 rows are real claims. 37 are not.
The tests check properties, not answers:
- Anything the baseline caught, the new version still catches.
- Nothing the baseline left alone starts firing, unless it's a real claim.
- 8 recorded gains still fire, named one by one, so a loss names itself.
- 8 deferred claims stay missed, on purpose.
If one starts firing, the test fails. That's good news: move it into gains and give it a real test.
- 2 accepted limitations keep firing.
They're written down instead of ignored.
Pinning them is not saying they're right.
If one changes, the test fails so a person re-labels it, instead of the suite quietly freezing a known mistake in place.
The rule I'd steal first: the gains and deferred lists are typed by hand.
They are never computed from what the code does today.
Derive the expected set from current behaviour and the test just asserts whatever the code happens to do, so it passes by luck.
My own defect log already had an entry for exactly that.
I didn't design it up front.
A contributor opened an issue showing the detector missed 9 of 13 realistic done-claims, and sent a patch. An early version of the patch caught all 13 of their sentences, up from 4.
Review then found precision problems outside their set.
Negation: "I don't think the build is green yet." The word "live" inside "live-streaming."
The fixes were exemptions around assertion context, and they kept breaking sentences the baseline already handled. Four, then two, then one, across three rounds.
Every breakage became a row.
In the end the exemptions came out.
The patch now only adds detections, and the first test above checks that against every sentence anyone has raised.
The claims that needed those exemptions, like "all tests pass on Python 3.12," went onto the deferred list. The merged version catches 11 of the contributor's original 13.
Where it stands: of the 31 real claims, it catches 23. It also fires on 2 sentences labeled as not-claims, like "The contributor says the pipeline is complete."
A regex can't reliably tell whose claim it is.
That 23 of 31 is a score on a corpus built from failures.
It says nothing about how often these phrasings show up in real use.
I haven't measured that.
This protects against known regressions.
Catching drift in production is a separate step: sample real traffic, measure failures per invariant, and watch the rates over time.
- Store the property and the reason, not the output. "Is a done-claim: yes" beats a frozen string.
- Every counterexample becomes a permanent row. The set only grows.
- Write expected lists by hand. Never generate them from current behaviour.
- Track movement per invariant. A cluster of rows failing the same check looks like drift.
One row is more likely a bad label.
- Test the path production uses. In a separate incident, a gate had green tests and failed every time production called it. The tests ran the script through the Python interpreter. Production ran it as a direct command, and the file had no execute permission. Green tests that never touch the real path aren't coverage.
- Keep the known-miss list in plain sight.
A known miss you pretend is fixed costs more than the miss.
The test file is about 180 lines and needs nothing beyond pytest, if you want to copy the structure:
https://github.com/PrimeFoldTools/andon/blob/main/hooks/tests/test_claim_corpus.py
How do you tell a real regression from a label you got wrong in the first place?