noisefloor

A regression harness for programs that don't give the same answer twice. It runs every test case several times, measures how far the results move on their own, and only calls a change a regression when it moves them further than that.

Eval tools answer “what score does this get?”. The question that blocks a merge is “did this change make it worse?”, and on a system that samples, two runs of unchanged code already disagree. So noisefloor measures the spread first, then judges the change against it.

View on GitHub Design notes

Plays the six stages below in order, about 60 seconds. Scroll or press Esc to stop.

Language
Python 3.11+
Tests
202 passing, no key, no network
Dependencies
pydantic and pyyaml, both pinned
Scorers
9 built in, none calls a model
Targets
Any program that prints JSON
License
MIT
noisefloor --root examples/rag-knowledge-agent/baseline diff rag-knowledge-agent: 20260903T014508.400182Z-rag-knowledge-agent-3dbc2f92 → 20260903T014835.696525Z-rag-knowledge-agent-3dbc2f92 5 unchanged

All three are real runs. Unchanged compares the committed capture of a real RAG agent, offline. Regressed uses an illustrative target written for this page. First run is the repo's quickstart.

1Run the target as argv, never a shell

A suite is a YAML file: one target command, and a list of cases, each with an input and the scorers that judge it. The command is a list of arguments, and the target is started without a shell.

  • {{input}}, {{case_id}} and {{repeat}} are filled in one pass per argument, so an input that contains the literal text {{repeat}} is not filled a second time.
  • Case inputs are often pasted from bug reports. Through a shell, every one of them would be a command. As one argument, it stays data.
  • Each case is hashed from its input and scorers. A case that changes meaning under the same id is left out of the comparison, with a warning, instead of being compared.

The cost is no pipes, globs or variable expansion in the command line. Those are exactly the features that make injection possible.

One hostile input, one argument Illustrative input

Case input Paris"; touch pwned.txt; echo "$(touch pwned2.txt)

The suite's command, one box per argument

  1. argv[0]python
  2. argv[1]echo_argv.py
  3. argv[2]{{input}}Paris"; touch pwned.txt; echo "$(touch pwned2.txt)
What the target received, from the stored stdout {"argv": ["Paris\"; touch pwned.txt; echo \"$(touch pwned2.txt)"]}
lsecho_argv.pysuite.yaml
Arguments the input became
1
Files the input created
0
The input was made up for this page; the run is real. noisefloor run started a script that prints its own arguments, and the stored output and the directory listing are exactly as printed. Neither pwned.txt nor pwned2.txt exists.

2Repeat every case and keep every output

Each case runs N times, five by default, one after another. Every repeat's stdout, stderr, exit code and duration is stored as JSON under .noisefloor/runs/, so nothing a target printed is thrown away.

  • A crash, a timeout or output that isn't JSON is recorded as an error, never as a pass or a fail. A crash is the absence of an answer, not a low score.
  • Files are written to a temporary name and renamed into place, so a crash mid-write can't leave half a record.
  • noisefloor rescore applies new or fixed scorers to runs already paid for, and noisefloor diff compares stored runs, without calling the target again.
  • Latency isn't scored under --jobs above 1. Timings taken in parallel contend with each other, so they aren't measurements.

A real RAG agent, 5 cases, 3 repeats each

0:000 of 15 repeats stored

runs/20260903T014508.400182Z-rag-knowledge-agent-3dbc2f92/cases/

Case order, repeat order and every duration come from the run files committed in examples/rag-knowledge-agent/baseline/: rag-knowledge-agent answering with claude-sonnet-4-6, 15 calls in 3 minutes 9 seconds, all ok. Played back 40 times faster.

3Score each repeat, then measure the band

Scorers are deterministic, so judging an answer never needs a model or a key. Each one returns a value and a pass flag for every repeat, and the repeats then collapse into one band per scorer: the noise floor that a change will be judged against.

  • Seven binary scorers, such as contains, regex and json_path_equals, and two continuous ones, json_path_number and latency.
  • A binary scorer becomes a pass count, like 3/3. A continuous one becomes a mean and the observed range from lowest to highest, printed as 0.543 [0.437–0.597] n=3.
  • json_path_number takes the lowest value by default, so every citation has to clear the bar, not just the best one.
  • One repeat has no spread. With a single repeat the report says “noise unmeasured” and calls nothing significant.

The range over a handful of repeats is not a confidence interval, and the README says so. It is a heuristic, and every report prints the counts behind it so a person can overrule it.

charging-bays, scored three times

QuestionHow many charging bays should we provision for a two-shift operation?

Scorer {type: json_path_number, path: "citations[].score", min: 0.35}

0:json_valid: 3/31:json_path_in: 3/32:json_path_number: 0.543 [0.437–0.597] n=3
Citations and scores are the stored outputs of the committed baseline run. In repeat 1 the model also cited the FAQ, so that repeat's lowest score, and its value, is 0.4374. The three band lines use the report's own format.

4Call it a regression only when it clears the noise

This is the line. It doesn't hold back code; it holds back the claim “this got worse” until the change moves the results further than they moved on their own. The baseline, measured before the change, is always the yardstick.

  • Continuous scorers: the candidate's mean has to land outside the baseline's band, on the worse side, and move by more than --min-effect, which is 0 unless you set it.
  • Binary scorers: from a baseline that passed every repeat, any failure counts, because a clean baseline showed no variance to absorb it. Otherwise the pass rate has to drop by more than 0.2. At five repeats, one extra failure is noise and two are not.
  • The candidate's own spread is never used as the yardstick, and the unanimous rule never runs in reverse: 4/5 to 5/5 is unchanged, not improved.

No t-test, on purpose: at five samples a p-value claims a precision the data can't support. A tolerance of 1e-9 keeps two drops of exactly 0.2, which floating point computes as 0.20000000000000007 and 0.19999999999999996, on the same side of the threshold.

One baseline band, three candidates

Baseline band
0.437–0.597
Candidate mean
0.597
Mean moved by
0.053
Verdict
Within noise
Exit code
0

Binary scorers: all 36 outcomes at five repeats

Rows are the baseline's passes, columns the candidate's. Hover or focus a cell for the reason the report prints.

RegressedImprovedWithin noiseAlso run end to end in stage 6
The real agent is rag-knowledge-agent, captured twice with nothing changed; values are from the committed run files. Small and large change use Illustrative input: a script written for this page that estimates pi from random points and reports its error, run for real, five repeats each. Every grid cell is the project's own compare() run on that pair of pass counts. Before issue #7 was fixed, the 4/5 to 5/5 cell read improved; it is the only cell that changed.

ClearedAbove this line noisefloor only measures. Below it, the claim “this got worse” leaves the tool: a verdict, the numbers behind it, and an exit code that fails the build.

5Report each case, worst first, with its numbers

Each scorer gets its own verdict, and a case takes the worst of them: any regressed scorer makes the case regressed, then any improved one. Cases are listed worst first, so the line that fails the build is the first one you read.

  • Order: broke, regressed, redefined, unmeasured, removed, added, fixed, improved, unchanged.
  • No verdict is printed without the counts and bands behind it: in the terminal, as JSON, or as a Markdown table for a pull request, with the same exit code in each.
  • Improvements are reported under the same thresholds, so a suspiciously large gain gets the same scrutiny as a loss.

The real capture, judged by two versions of the rule

5 unchangedexit 0

    Both outputs were re-run offline on the committed run files, with no model call: today's code, and the code at 524d5cd, just before the fix. The old rule measured the baseline against the candidate's zero-width band (three repeats at 0.5965) by making the call with the two swapped, which is also why its reason reads backwards. Fixed in ade90b8, and the wording in 206e4a9.

    6Fail the build with a code CI can read

    The exit code is the verdict CI acts on: 0 when nothing regressed, 1 when a case regressed, 2 when a case broke, and 3 when the harness or its configuration is wrong.

    • Broke outranks regressed. A target that now fails on every repeat is a more urgent finding than one that got somewhat worse, and CI can tell the two apart without reading the report.
    • A changed target command refuses to compare unless you pass --allow-target-change. Comparing two different programs is occasionally what you want and usually a mistake.
    • The first check with no baseline adopts its own run and exits 0. That is a pass by absence, so commit a baseline before trusting the gate.

    Four changes, four exit codes Illustrative input

    A small suite and target written for this page, changed four ways and run for real with noisefloor 0.1.0; output is verbatim, and exit codes are the shell's. The broke run's stored stderr ends in IndexError: list index out of range. Choose a card to see its run.

    Measured on a real agent

    The README publishes one real capture: rag-knowledge-agent on claude-sonnet-4-6 with no temperature set, 5 cases, 3 repeats, captured twice with nothing changed in between, 30 model calls. Every number here can be re-checked offline from the committed run files.

    What moved

    Fifteen binary scorers held at 3/3 in both captures. The one number that moved was a lowest citation score, because in one repeat the model chose to cite a second document.

    15 binary, 3/3 → 3/31 continuous moved5 unchanged

    Retrieval scored each document the same in every repeat: 0.5965 for the product spec, 0.4374 for the FAQ whenever it appeared.

    What a real target found

    The first comparison of the two captures said 1 improved, 4 unchanged, on an agent that had not changed. 182 tests had passed; one 15-call capture found the hole.

    Before the fix182
    The fix191
    Issue #7 fixed202

    Tests collected at each commit. The fix was checked against the stored runs, so it cost no extra model calls.

    What the scorers can't see

    The suite checks structure and substrings, so a reworded answer passes as stable. The stored output shows how often the wording changed: distinct answers across the six stored repeats.

    Error 4096 of 6
    Offline robot6 of 6
    Charging bays3 of 6
    Each refusal1 of 6

    The refusals came back word for word; the answers did not. The README names this as a limit of the suite, not a finding about the agent.

    202 tests, no key, no network

    A deterministic fake target reaches every failure mode from flags. Its flakiness is a function of the repeat index, so a three-out-of-five baseline is exactly reproducible without a random seed.

      Weighted toward the verdict

      • Significance rules: the unanimous rule, the 0.2 drop, drops of exactly the threshold that must never flag, bands, minimum effect, direction, and reasons that read baseline first.
      • Verdicts: broke outranks regressed, fixed doesn't fail the build, redefined cases are left out, a changed target is refused, and cases sort worst first.
      • Exit codes: 14 tests make sure a configuration error or a corrupted stored run exits 3, never with a traceback or a false 1.
      • Target adapter: shell metacharacters stay one argument, no placeholder is filled twice, and each kind of failure gets its own outcome.
      • The committed capture: the real baseline loads, renders in every format, and contains no absolute paths, usernames or key prefixes.

      CI runs ruff, pytest and the quickstart from a clean checkout on Python 3.11 and 3.12.

      Deliberately missing

      A real statistical test
      At five samples a t-test or a confidence interval manufactures precision. Bootstrap intervals are on the roadmap for suites that can afford many repeats.
      LLM-as-judge scorers
      A judge needs an API key, and the key-free test suite is load-bearing. Planned behind an explicit flag, never as a default.
      An HTTP adapter
      The subprocess adapter covers the first real target. A second transport waits for a need that has actually shown up.
      Multi-turn targets
      Conversation history would add a second source of variation that the statistics don't model.
      Cost accounting
      The harness only sees a target's stdout, so it has no view of tokens.

      Run it

      git clone https://github.com/ahmed-hashim-pro/noisefloor.git
      cd noisefloor
      python -m venv .venv && . .venv/bin/activate
      pip install -e .
      
      # The first check adopts a baseline. Run it again to compare.
      noisefloor check examples/quickstart/suite.yaml
      noisefloor check examples/quickstart/suite.yaml
      
      # The real RAG capture, compared offline. No key needed.
      noisefloor --root examples/rag-knowledge-agent/baseline diff
      
      pip install -e ".[dev]"
      pytest

      Two runtime dependencies, both pinned: pydantic and pyyaml. noisefloor itself never needs an API key; only your target might.