lcb-gate 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ * text=auto eol=lf
@@ -0,0 +1,31 @@
1
+ name: ci
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ${{ matrix.os }}
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ os: [ubuntu-latest]
15
+ python-version: ["3.9", "3.13"]
16
+ include:
17
+ - os: windows-latest
18
+ python-version: "3.13"
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+ - uses: actions/setup-python@v5
22
+ with:
23
+ python-version: ${{ matrix.python-version }}
24
+ - name: Install
25
+ run: pip install -e ".[dev]"
26
+ - name: Test suite
27
+ run: pytest -q
28
+ - name: Zero-dependency check (tests run on bare python)
29
+ run: python tests/test_lcb.py
30
+ - name: Demo
31
+ run: python examples/demo.py
@@ -0,0 +1,44 @@
1
+ name: release
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ jobs:
8
+ build:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - uses: actions/setup-python@v5
13
+ with:
14
+ python-version: "3.13"
15
+ - name: Test
16
+ run: |
17
+ pip install -e ".[dev]"
18
+ pytest -q
19
+ - name: Build sdist and wheel
20
+ run: |
21
+ pip install build twine
22
+ python -m build
23
+ twine check dist/*
24
+ - uses: actions/upload-artifact@v4
25
+ with:
26
+ name: dist
27
+ path: dist/
28
+
29
+ publish:
30
+ needs: build
31
+ runs-on: ubuntu-latest
32
+ # Trusted Publishing (OIDC): configure this repo + workflow + environment
33
+ # as a (pending) publisher for the project on pypi.org — no API token.
34
+ environment:
35
+ name: pypi
36
+ url: https://pypi.org/p/lcb-gate
37
+ permissions:
38
+ id-token: write
39
+ steps:
40
+ - uses: actions/download-artifact@v4
41
+ with:
42
+ name: dist
43
+ path: dist/
44
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ dist/
6
+ build/
7
+ .venv/
8
+ venv/
9
+ .idea/
10
+ .vscode/
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-07-10
4
+
5
+ Initial release.
6
+
7
+ - `wilson_lcb` / `min_trials` — one-sided Wilson score lower bound and gate sizing.
8
+ - `run_gate` — N-run pass/fail gating with settled-verdict early stopping.
9
+ - `compare` — paired champion/candidate comparison (common random numbers,
10
+ ties count as half a win), verdict on the win-rate LCB vs 0.5.
11
+ - pytest plugin: `lcb` fixture (`check`, `check_better`) via the `pytest11`
12
+ entry point.
lcb_gate-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ciphemon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,160 @@
1
+ Metadata-Version: 2.4
2
+ Name: lcb-gate
3
+ Version: 0.1.0
4
+ Summary: Statistical pass/fail gating for stochastic tests: run N times, pass only when the Wilson lower confidence bound clears the bar. Built for agent evals; stdlib only.
5
+ Project-URL: Homepage, https://github.com/CiphemonJY/lcb-gate
6
+ Project-URL: Repository, https://github.com/CiphemonJY/lcb-gate
7
+ Project-URL: Issues, https://github.com/CiphemonJY/lcb-gate/issues
8
+ Project-URL: Changelog, https://github.com/CiphemonJY/lcb-gate/blob/main/CHANGELOG.md
9
+ Author: Ciphemon
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: agent-evals,confidence-interval,flaky-tests,llm,pytest,statistics,testing
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Framework :: Pytest
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Software Development :: Quality Assurance
23
+ Requires-Python: >=3.9
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=7; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # lcb-gate
29
+
30
+ [![ci](https://github.com/CiphemonJY/lcb-gate/actions/workflows/ci.yml/badge.svg)](https://github.com/CiphemonJY/lcb-gate/actions/workflows/ci.yml)
31
+
32
+ **Statistical pass/fail gating for stochastic tests.** Run the trial N times;
33
+ pass only when the **Wilson lower confidence bound** of the pass rate clears
34
+ your threshold. Built for agent evals — anything where a single green run
35
+ proves almost nothing. Stdlib only, no dependencies.
36
+
37
+ ```
38
+ pip install lcb-gate
39
+ ```
40
+
41
+ The demo ships in the repo (not the wheel):
42
+
43
+ ```
44
+ git clone https://github.com/CiphemonJY/lcb-gate && cd lcb-gate
45
+ python examples/demo.py # why a single green run lies, in three acts
46
+ ```
47
+
48
+ ## The problem
49
+
50
+ Agent behaviors, LLM evals, and flaky integration tests are *stochastic*. CI
51
+ treats them as deterministic: one green run → merged. Two failure modes follow:
52
+
53
+ - **The lucky pass.** A 75%-reliable agent task passes a single CI run 75% of
54
+ the time. You ship a coin flip and call it tested.
55
+ - **The winner's curse.** You try 20 variants, one "beats the baseline" on a
56
+ single eval run, and you promote it. Most single-run wins are noise: in one
57
+ production self-improvement pipeline, fresh-seed re-verification killed
58
+ **85% of apparent improvements** found by a first-pass scan.
59
+
60
+ Averages don't fix this — a point estimate without n is a vibe. What fixes it
61
+ is a *lower confidence bound*: "with 95% confidence the true pass rate is at
62
+ least X". Gate on X.
63
+
64
+ ## Usage
65
+
66
+ ### As a library
67
+
68
+ ```python
69
+ from lcb_gate import run_gate
70
+
71
+ result = run_gate(lambda i: run_my_agent(seed=i).succeeded, n=25, threshold=0.9)
72
+ print(result)
73
+ # PASS: 25/25 passed (gate n=25); LCB 0.902 >= 0.9 @ 95% confidence
74
+ assert result.passed
75
+ ```
76
+
77
+ `run_gate` stops early — in either direction — the moment the verdict is
78
+ mathematically settled, so hopeless runs don't burn the full budget. The
79
+ verdict is identical to running all n trials.
80
+
81
+ ### As a pytest fixture
82
+
83
+ ```python
84
+ def test_agent_completes_checkout(lcb):
85
+ lcb.check(lambda i: run_agent_eval(seed=i).ok, n=25, threshold=0.9)
86
+ ```
87
+
88
+ The plugin registers automatically on install. Failures explain themselves:
89
+
90
+ ```
91
+ Failed: lcb-gate FAIL: 21/30 passed (gate n=30); LCB 0.551 < 0.9 @ 95% confidence
92
+ ```
93
+
94
+ ### Candidate vs champion (promotion gates)
95
+
96
+ "Is the new prompt/model/policy actually better?" is the same statistics with
97
+ a different threshold — the win-rate LCB must exceed 0.5:
98
+
99
+ ```python
100
+ from lcb_gate import compare
101
+
102
+ result = compare(candidate=eval_new, champion=eval_old, n=400)
103
+ print(result)
104
+ # BETTER: candidate won 243.0/400 paired trials (60.8%); win-rate LCB 0.567 > 0.5 @ 95%
105
+ ```
106
+
107
+ Both callables receive the **same seed per trial** (common random numbers):
108
+ pairing removes the noise both variants share, which is where most of the
109
+ statistical power comes from. Ties count as half a win. The honest default
110
+ verdict is `NOT PROVEN` — an underpowered comparison never certifies.
111
+
112
+ ```python
113
+ def test_new_prompt_beats_production(lcb):
114
+ lcb.check_better(eval_new, eval_old, n=400)
115
+ ```
116
+
117
+ ## Sizing your gate
118
+
119
+ A perfect record at small n still can't clear a high bar — by design. Minimum
120
+ trials for a *flawless* run to pass, at 95% confidence:
121
+
122
+ | threshold | min n (all passing) |
123
+ |-----------|---------------------|
124
+ | 0.80 | 11 |
125
+ | 0.90 | 25 |
126
+ | 0.95 | 52 |
127
+ | 0.99 | 268 |
128
+
129
+ `min_trials(threshold)` computes this. If your eval budget can't afford the
130
+ n, lower the threshold honestly rather than pretending n=5 certifies 99%.
131
+
132
+ ## CI for agents — the full stack
133
+
134
+ This gate answers "does the behavior hold up statistically **across runs**?"
135
+ Its sibling project [grounding-gate](https://github.com/CiphemonJY/grounding-gate)
136
+ answers "was each claim structurally grounded **within a run**?" Together:
137
+
138
+ 1. **Per-trace, structural**: grounding-gate rejects terminals whose claims
139
+ were never observed (zero tokens, zero LLM calls).
140
+ 2. **Across-runs, statistical**: lcb-gate runs the eval N times and certifies
141
+ the pass rate's lower bound.
142
+ 3. **On promotion**: `compare()` with paired seeds gates champion swaps.
143
+
144
+ A GitHub Actions job needs nothing special — the gate lives inside the tests:
145
+
146
+ ```yaml
147
+ - run: pip install lcb-gate
148
+ - run: pytest tests/agent_evals -q # each test is an N-run certified gate
149
+ ```
150
+
151
+ ## Roadmap
152
+
153
+ - Sequential probability ratio test (SPRT) mode — even fewer trials at the
154
+ same error rates.
155
+ - `@pytest.mark.lcb(n=..., threshold=...)` marker API.
156
+ - JSON report artifact for tracking LCBs over time.
157
+
158
+ ## License
159
+
160
+ MIT
@@ -0,0 +1,133 @@
1
+ # lcb-gate
2
+
3
+ [![ci](https://github.com/CiphemonJY/lcb-gate/actions/workflows/ci.yml/badge.svg)](https://github.com/CiphemonJY/lcb-gate/actions/workflows/ci.yml)
4
+
5
+ **Statistical pass/fail gating for stochastic tests.** Run the trial N times;
6
+ pass only when the **Wilson lower confidence bound** of the pass rate clears
7
+ your threshold. Built for agent evals — anything where a single green run
8
+ proves almost nothing. Stdlib only, no dependencies.
9
+
10
+ ```
11
+ pip install lcb-gate
12
+ ```
13
+
14
+ The demo ships in the repo (not the wheel):
15
+
16
+ ```
17
+ git clone https://github.com/CiphemonJY/lcb-gate && cd lcb-gate
18
+ python examples/demo.py # why a single green run lies, in three acts
19
+ ```
20
+
21
+ ## The problem
22
+
23
+ Agent behaviors, LLM evals, and flaky integration tests are *stochastic*. CI
24
+ treats them as deterministic: one green run → merged. Two failure modes follow:
25
+
26
+ - **The lucky pass.** A 75%-reliable agent task passes a single CI run 75% of
27
+ the time. You ship a coin flip and call it tested.
28
+ - **The winner's curse.** You try 20 variants, one "beats the baseline" on a
29
+ single eval run, and you promote it. Most single-run wins are noise: in one
30
+ production self-improvement pipeline, fresh-seed re-verification killed
31
+ **85% of apparent improvements** found by a first-pass scan.
32
+
33
+ Averages don't fix this — a point estimate without n is a vibe. What fixes it
34
+ is a *lower confidence bound*: "with 95% confidence the true pass rate is at
35
+ least X". Gate on X.
36
+
37
+ ## Usage
38
+
39
+ ### As a library
40
+
41
+ ```python
42
+ from lcb_gate import run_gate
43
+
44
+ result = run_gate(lambda i: run_my_agent(seed=i).succeeded, n=25, threshold=0.9)
45
+ print(result)
46
+ # PASS: 25/25 passed (gate n=25); LCB 0.902 >= 0.9 @ 95% confidence
47
+ assert result.passed
48
+ ```
49
+
50
+ `run_gate` stops early — in either direction — the moment the verdict is
51
+ mathematically settled, so hopeless runs don't burn the full budget. The
52
+ verdict is identical to running all n trials.
53
+
54
+ ### As a pytest fixture
55
+
56
+ ```python
57
+ def test_agent_completes_checkout(lcb):
58
+ lcb.check(lambda i: run_agent_eval(seed=i).ok, n=25, threshold=0.9)
59
+ ```
60
+
61
+ The plugin registers automatically on install. Failures explain themselves:
62
+
63
+ ```
64
+ Failed: lcb-gate FAIL: 21/30 passed (gate n=30); LCB 0.551 < 0.9 @ 95% confidence
65
+ ```
66
+
67
+ ### Candidate vs champion (promotion gates)
68
+
69
+ "Is the new prompt/model/policy actually better?" is the same statistics with
70
+ a different threshold — the win-rate LCB must exceed 0.5:
71
+
72
+ ```python
73
+ from lcb_gate import compare
74
+
75
+ result = compare(candidate=eval_new, champion=eval_old, n=400)
76
+ print(result)
77
+ # BETTER: candidate won 243.0/400 paired trials (60.8%); win-rate LCB 0.567 > 0.5 @ 95%
78
+ ```
79
+
80
+ Both callables receive the **same seed per trial** (common random numbers):
81
+ pairing removes the noise both variants share, which is where most of the
82
+ statistical power comes from. Ties count as half a win. The honest default
83
+ verdict is `NOT PROVEN` — an underpowered comparison never certifies.
84
+
85
+ ```python
86
+ def test_new_prompt_beats_production(lcb):
87
+ lcb.check_better(eval_new, eval_old, n=400)
88
+ ```
89
+
90
+ ## Sizing your gate
91
+
92
+ A perfect record at small n still can't clear a high bar — by design. Minimum
93
+ trials for a *flawless* run to pass, at 95% confidence:
94
+
95
+ | threshold | min n (all passing) |
96
+ |-----------|---------------------|
97
+ | 0.80 | 11 |
98
+ | 0.90 | 25 |
99
+ | 0.95 | 52 |
100
+ | 0.99 | 268 |
101
+
102
+ `min_trials(threshold)` computes this. If your eval budget can't afford the
103
+ n, lower the threshold honestly rather than pretending n=5 certifies 99%.
104
+
105
+ ## CI for agents — the full stack
106
+
107
+ This gate answers "does the behavior hold up statistically **across runs**?"
108
+ Its sibling project [grounding-gate](https://github.com/CiphemonJY/grounding-gate)
109
+ answers "was each claim structurally grounded **within a run**?" Together:
110
+
111
+ 1. **Per-trace, structural**: grounding-gate rejects terminals whose claims
112
+ were never observed (zero tokens, zero LLM calls).
113
+ 2. **Across-runs, statistical**: lcb-gate runs the eval N times and certifies
114
+ the pass rate's lower bound.
115
+ 3. **On promotion**: `compare()` with paired seeds gates champion swaps.
116
+
117
+ A GitHub Actions job needs nothing special — the gate lives inside the tests:
118
+
119
+ ```yaml
120
+ - run: pip install lcb-gate
121
+ - run: pytest tests/agent_evals -q # each test is an N-run certified gate
122
+ ```
123
+
124
+ ## Roadmap
125
+
126
+ - Sequential probability ratio test (SPRT) mode — even fewer trials at the
127
+ same error rates.
128
+ - `@pytest.mark.lcb(n=..., threshold=...)` marker API.
129
+ - JSON report artifact for tracking LCBs over time.
130
+
131
+ ## License
132
+
133
+ MIT
@@ -0,0 +1,69 @@
1
+ """Why a single green run lies, in three acts. Deterministic (seeded), stdlib only.
2
+
3
+ Run: ``python examples/demo.py`` (asserts its own expected outcomes; exit 0).
4
+ """
5
+
6
+ import random
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ try:
11
+ import lcb_gate # noqa: F401
12
+ except ImportError:
13
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
14
+
15
+ from lcb_gate import compare, min_trials, run_gate
16
+
17
+
18
+ def flaky_eval(true_pass_rate, salt):
19
+ """A stochastic 'agent eval': passes with the given true probability."""
20
+ def trial(i):
21
+ # str seed: stable across Python versions AND legal on 3.13+
22
+ # (tuple seeds became a TypeError in 3.13)
23
+ return random.Random(f"{salt}:{i}").random() < true_pass_rate
24
+ return trial
25
+
26
+
27
+ print("ACT 1 — the 75% eval that looks fine once")
28
+ trial = flaky_eval(0.75, salt=101)
29
+ singles = [trial(i) for i in range(5)]
30
+ print(f" five individual CI runs: {['green' if s else 'RED' for s in singles]}")
31
+ res = run_gate(trial, n=30, threshold=0.9)
32
+ print(f" gate verdict: {res}")
33
+ assert any(singles) # at least one single run looked green
34
+ assert not res.passed # the gate is not fooled
35
+
36
+ print("\nACT 2 — the genuinely good eval, and what n it takes")
37
+ trial = flaky_eval(0.97, salt=202)
38
+ small = run_gate(trial, n=25, threshold=0.9)
39
+ large = run_gate(trial, n=150, threshold=0.9)
40
+ print(f" n=25: {small}")
41
+ print(f" n=150: {large}")
42
+ print(f" (a PERFECT record needs n >= {min_trials(0.9)} to clear 0.9 at 95%)")
43
+ assert large.passed
44
+
45
+ print("\nACT 3 — candidate vs champion, paired seeds (common random numbers)")
46
+
47
+
48
+ def champion(seed):
49
+ return random.Random(seed).random()
50
+
51
+
52
+ def candidate(seed):
53
+ # same shared noise as the champion (CRN), plus a real but small edge:
54
+ # wins the pair 60% of the time
55
+ r = random.Random(seed)
56
+ base = r.random()
57
+ return base + (0.01 if r.random() < 0.6 else -0.01)
58
+
59
+
60
+ underpowered = compare(candidate, champion, seeds=range(30))
61
+ powered = compare(candidate, champion, seeds=range(400))
62
+ print(f" n=30: {underpowered}")
63
+ print(f" n=400: {powered}")
64
+ print(" same candidate, same true edge — only the evidence changed")
65
+ print(" (30 pairs is so underpowered the sample can even reverse the sign)")
66
+ assert not underpowered.better # honest: cannot certify on 30 pairs
67
+ assert powered.better # certified on 400
68
+
69
+ print("\nAll demo assertions passed.")
@@ -0,0 +1,52 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "lcb-gate"
7
+ version = "0.1.0"
8
+ description = "Statistical pass/fail gating for stochastic tests: run N times, pass only when the Wilson lower confidence bound clears the bar. Built for agent evals; stdlib only."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.9"
13
+ authors = [{ name = "Ciphemon" }]
14
+ keywords = [
15
+ "testing",
16
+ "flaky-tests",
17
+ "agent-evals",
18
+ "llm",
19
+ "statistics",
20
+ "confidence-interval",
21
+ "pytest",
22
+ ]
23
+ classifiers = [
24
+ "Development Status :: 3 - Alpha",
25
+ "Framework :: Pytest",
26
+ "Intended Audience :: Developers",
27
+ "Programming Language :: Python :: 3",
28
+ "Programming Language :: Python :: 3.9",
29
+ "Programming Language :: Python :: 3.10",
30
+ "Programming Language :: Python :: 3.11",
31
+ "Programming Language :: Python :: 3.12",
32
+ "Programming Language :: Python :: 3.13",
33
+ "Topic :: Software Development :: Quality Assurance",
34
+ ]
35
+
36
+ [project.optional-dependencies]
37
+ dev = ["pytest>=7"]
38
+
39
+ [project.entry-points.pytest11]
40
+ lcb_gate = "lcb_gate.plugin"
41
+
42
+ [project.urls]
43
+ Homepage = "https://github.com/CiphemonJY/lcb-gate"
44
+ Repository = "https://github.com/CiphemonJY/lcb-gate"
45
+ Issues = "https://github.com/CiphemonJY/lcb-gate/issues"
46
+ Changelog = "https://github.com/CiphemonJY/lcb-gate/blob/main/CHANGELOG.md"
47
+
48
+ [tool.hatch.build.targets.wheel]
49
+ packages = ["src/lcb_gate"]
50
+
51
+ [tool.pytest.ini_options]
52
+ testpaths = ["tests"]
@@ -0,0 +1,22 @@
1
+ """lcb-gate — statistical pass/fail gating for stochastic tests.
2
+
3
+ A single green run of a stochastic test proves almost nothing. This package
4
+ runs the trial N times and passes only when the Wilson lower confidence bound
5
+ of the observed pass rate clears your threshold — so "it passed" means
6
+ "it passes at ≥ X% with statistical backing", not "it got lucky once".
7
+ """
8
+
9
+ from .stats import min_trials, wilson_lcb
10
+ from .gate import CompareResult, GateResult, compare, run_gate
11
+
12
+ __version__ = "0.1.0"
13
+
14
+ __all__ = [
15
+ "CompareResult",
16
+ "GateResult",
17
+ "compare",
18
+ "min_trials",
19
+ "run_gate",
20
+ "wilson_lcb",
21
+ "__version__",
22
+ ]
@@ -0,0 +1,90 @@
1
+ """The gate: N-run consensus with an LCB verdict, and paired A/B comparison."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from .stats import wilson_lcb
6
+
7
+
8
+ @dataclass
9
+ class GateResult:
10
+ passes: int
11
+ n: int # the gate's denominator — unrun trials count as failures
12
+ trials_run: int
13
+ lcb: float
14
+ threshold: float
15
+ confidence: float
16
+ passed: bool
17
+
18
+ def __str__(self):
19
+ verdict = "PASS" if self.passed else "FAIL"
20
+ return (f"{verdict}: {self.passes}/{self.trials_run} passed "
21
+ f"(gate n={self.n}); LCB {self.lcb:.3f} "
22
+ f"{'>=' if self.passed else '<'} {self.threshold} "
23
+ f"@ {self.confidence:.0%} confidence")
24
+
25
+
26
+ def run_gate(trial, n=25, threshold=0.9, confidence=0.95, stop_early=True):
27
+ """Run ``trial(i) -> bool`` up to ``n`` times; pass iff the Wilson LCB of
28
+ the pass rate (over the FIXED denominator n) clears ``threshold``.
29
+
30
+ Unrun trials count as failures, so the reported bound is always
31
+ conservative. With ``stop_early`` the loop exits as soon as the verdict is
32
+ mathematically settled either way — the verdict is identical to running
33
+ all n trials, only cheaper.
34
+ """
35
+ passes = 0
36
+ ran = 0
37
+ for i in range(n):
38
+ # verdict already settled?
39
+ if stop_early:
40
+ if wilson_lcb(passes, n, confidence) >= threshold:
41
+ break # pass even if the rest fail
42
+ if wilson_lcb(passes + (n - ran), n, confidence) < threshold:
43
+ break # fail even if the rest pass
44
+ passes += 1 if trial(i) else 0
45
+ ran += 1
46
+ lcb = wilson_lcb(passes, n, confidence)
47
+ return GateResult(passes=passes, n=n, trials_run=ran, lcb=lcb,
48
+ threshold=threshold, confidence=confidence,
49
+ passed=lcb >= threshold)
50
+
51
+
52
+ @dataclass
53
+ class CompareResult:
54
+ wins: float # ties count 0.5
55
+ n: int
56
+ win_rate: float
57
+ lcb: float
58
+ confidence: float
59
+ better: bool # LCB of the win rate > 0.5
60
+
61
+ def __str__(self):
62
+ verdict = "BETTER" if self.better else "NOT PROVEN"
63
+ return (f"{verdict}: candidate won {self.wins}/{self.n} paired trials "
64
+ f"({self.win_rate:.1%}); win-rate LCB {self.lcb:.3f} "
65
+ f"{'>' if self.better else '<='} 0.5 @ {self.confidence:.0%}")
66
+
67
+
68
+ def compare(candidate, champion, n=200, confidence=0.95, seeds=None):
69
+ """Paired comparison: is ``candidate`` better than ``champion``?
70
+
71
+ Both callables receive the SAME seed per trial (common random numbers) —
72
+ pairing removes shared noise, which is where most of the statistical power
73
+ comes from. Callables return a comparable score (float or bool); ties
74
+ count as half a win. Verdict ``better`` requires the win-rate LCB to
75
+ exceed 0.5 — "not proven" is the honest default for underpowered n.
76
+ """
77
+ seed_list = list(seeds) if seeds is not None else list(range(n))
78
+ total = len(seed_list)
79
+ if total == 0:
80
+ raise ValueError("compare() needs at least one seed")
81
+ wins = 0.0
82
+ for s in seed_list:
83
+ c, ch = candidate(s), champion(s)
84
+ if c > ch:
85
+ wins += 1.0
86
+ elif c == ch:
87
+ wins += 0.5
88
+ lcb = wilson_lcb(wins, total, confidence)
89
+ return CompareResult(wins=wins, n=total, win_rate=wins / total, lcb=lcb,
90
+ confidence=confidence, better=lcb > 0.5)
@@ -0,0 +1,36 @@
1
+ """pytest integration: the ``lcb`` fixture.
2
+
3
+ Usage::
4
+
5
+ def test_agent_completes_task(lcb):
6
+ lcb.check(lambda i: run_my_agent(seed=i).succeeded, n=25, threshold=0.9)
7
+
8
+ The test fails with a statistical explanation unless the Wilson LCB of the
9
+ pass rate clears the threshold.
10
+ """
11
+
12
+ import pytest
13
+
14
+ from .gate import compare, run_gate
15
+
16
+
17
+ class LcbHelper:
18
+ def check(self, trial, n=25, threshold=0.9, confidence=0.95, stop_early=True):
19
+ """Fail the surrounding test unless the gate passes. Returns GateResult."""
20
+ result = run_gate(trial, n=n, threshold=threshold,
21
+ confidence=confidence, stop_early=stop_early)
22
+ if not result.passed:
23
+ pytest.fail(f"lcb-gate {result}")
24
+ return result
25
+
26
+ def check_better(self, candidate, champion, n=200, confidence=0.95, seeds=None):
27
+ """Fail the surrounding test unless candidate provably beats champion."""
28
+ result = compare(candidate, champion, n=n, confidence=confidence, seeds=seeds)
29
+ if not result.better:
30
+ pytest.fail(f"lcb-gate {result}")
31
+ return result
32
+
33
+
34
+ @pytest.fixture
35
+ def lcb():
36
+ return LcbHelper()
@@ -0,0 +1,38 @@
1
+ """Wilson score interval, lower bound — the workhorse statistic.
2
+
3
+ The Wilson bound is preferred over the normal (Wald) approximation because it
4
+ behaves at the edges: a perfect record never yields LCB = 1.0, and small n
5
+ yields honestly wide bounds. ``successes`` may be fractional (ties in paired
6
+ comparisons count as half a win).
7
+ """
8
+
9
+ import math
10
+ from statistics import NormalDist
11
+
12
+
13
+ def wilson_lcb(successes, n, confidence=0.95):
14
+ """One-sided Wilson score lower bound for a binomial proportion.
15
+
16
+ Returns 0.0 for n == 0 (no evidence -> no lower bound).
17
+ """
18
+ if n == 0:
19
+ return 0.0
20
+ z = NormalDist().inv_cdf(confidence)
21
+ phat = successes / n
22
+ denom = 1 + z * z / n
23
+ center = phat + z * z / (2 * n)
24
+ margin = z * math.sqrt(phat * (1 - phat) / n + z * z / (4 * n * n))
25
+ return max(0.0, (center - margin) / denom)
26
+
27
+
28
+ def min_trials(threshold, confidence=0.95):
29
+ """Smallest n such that a PERFECT record clears ``threshold``.
30
+
31
+ For phat = 1 the Wilson LCB reduces to 1 / (1 + z²/n), so the requirement
32
+ is n >= z² · threshold / (1 - threshold). Useful for sizing a gate: below
33
+ this n, the gate is unpassable even by a flawless run — by design.
34
+ """
35
+ if not 0 < threshold < 1:
36
+ raise ValueError("threshold must be in (0, 1)")
37
+ z = NormalDist().inv_cdf(confidence)
38
+ return math.ceil(z * z * threshold / (1 - threshold))
@@ -0,0 +1,19 @@
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ # allow running the suite from a raw checkout, before `pip install -e .`
5
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
6
+
7
+ try:
8
+ from importlib.metadata import distribution
9
+
10
+ distribution("lcb-gate")
11
+ _installed = True # the pytest11 entry point already registers the plugin
12
+ except Exception:
13
+ _installed = False
14
+
15
+ if not _installed:
16
+ # Raw checkout only: no entry point exists, so load the plugin explicitly.
17
+ # Doing this unconditionally double-registers the module once the package
18
+ # is installed, and pluggy crashes before collecting a single test.
19
+ pytest_plugins = ["lcb_gate.plugin"]
@@ -0,0 +1,128 @@
1
+ """Core suite — deterministic, zero dependencies: ``python tests/test_lcb.py``."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ try:
7
+ import lcb_gate # noqa: F401
8
+ except ImportError:
9
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
10
+
11
+ from lcb_gate import compare, min_trials, run_gate, wilson_lcb
12
+
13
+
14
+ # ------------------------------------------------------------------ stats
15
+
16
+ def test_wilson_known_value_90_of_100():
17
+ # hand-computed: z=1.6449, LCB ≈ 0.8396
18
+ assert abs(wilson_lcb(90, 100, 0.95) - 0.8396) < 1e-3
19
+
20
+
21
+ def test_wilson_perfect_record_closed_form():
22
+ # for phat=1 the bound reduces to 1/(1+z²/n)
23
+ z2 = 1.6448536269514722 ** 2
24
+ assert abs(wilson_lcb(20, 20, 0.95) - 1 / (1 + z2 / 20)) < 1e-9
25
+
26
+
27
+ def test_wilson_edges():
28
+ assert wilson_lcb(0, 0) == 0.0
29
+ assert wilson_lcb(0, 10) == 0.0
30
+
31
+
32
+ def test_wilson_never_exceeds_point_estimate():
33
+ for s, n in [(1, 2), (5, 10), (45, 50), (99, 100)]:
34
+ assert wilson_lcb(s, n) <= s / n
35
+
36
+
37
+ def test_wilson_tightens_with_n():
38
+ assert wilson_lcb(9, 10) < wilson_lcb(90, 100) < wilson_lcb(900, 1000)
39
+
40
+
41
+ def test_min_trials_table():
42
+ assert min_trials(0.8) == 11
43
+ assert min_trials(0.9) == 25
44
+ assert min_trials(0.95) == 52
45
+
46
+
47
+ def test_min_trials_is_exact_boundary():
48
+ n = min_trials(0.9)
49
+ assert wilson_lcb(n, n) >= 0.9 # perfect record at n passes
50
+ assert wilson_lcb(n - 1, n - 1) < 0.9 # one fewer trial cannot pass
51
+
52
+
53
+ # ------------------------------------------------------------------ gate
54
+
55
+ def test_gate_passes_one_failure_in_twenty():
56
+ res = run_gate(lambda i: i != 3, n=20, threshold=0.75)
57
+ assert res.passed and res.passes == 19
58
+
59
+
60
+ def test_gate_fails_chronic_flake():
61
+ res = run_gate(lambda i: i % 2 == 0, n=20, threshold=0.9) # 50% pass rate
62
+ assert not res.passed
63
+
64
+
65
+ def test_gate_early_stop_on_hopeless_run():
66
+ res = run_gate(lambda i: False, n=100, threshold=0.9)
67
+ assert not res.passed
68
+ assert res.trials_run < 100 # settled long before 100 runs
69
+
70
+
71
+ def test_gate_early_stop_verdict_matches_full_run():
72
+ trial = lambda i: i % 7 != 0 # noqa: E731 — ~86% pass rate, deterministic
73
+ early = run_gate(trial, n=50, threshold=0.7, stop_early=True)
74
+ full = run_gate(trial, n=50, threshold=0.7, stop_early=False)
75
+ assert early.passed == full.passed
76
+
77
+
78
+ def test_gate_perfect_record_at_min_trials():
79
+ n = min_trials(0.9)
80
+ res = run_gate(lambda i: True, n=n, threshold=0.9)
81
+ assert res.passed
82
+
83
+
84
+ # ------------------------------------------------------------------ compare
85
+
86
+ def _champ(seed):
87
+ return (seed % 100) / 100
88
+
89
+
90
+ def test_compare_detects_70pct_winner():
91
+ def cand(seed):
92
+ return _champ(seed) + (0.01 if seed % 10 < 7 else -0.01)
93
+ res = compare(cand, _champ, seeds=range(100))
94
+ assert res.better
95
+ assert abs(res.win_rate - 0.7) < 1e-9
96
+
97
+
98
+ def test_compare_null_candidate_not_proven():
99
+ res = compare(_champ, _champ, seeds=range(100)) # all ties -> 0.5 win rate
100
+ assert not res.better
101
+ assert res.win_rate == 0.5
102
+
103
+
104
+ def test_compare_underpowered_n_not_proven():
105
+ def cand(seed):
106
+ return _champ(seed) + (0.01 if seed % 10 < 6 else -0.01) # true 60% winner
107
+ assert not compare(cand, _champ, seeds=range(10)).better # n=10: can't know
108
+ assert compare(cand, _champ, seeds=range(500)).better # n=500: proven
109
+
110
+
111
+ # ------------------------------------------------------- bare-python runner
112
+
113
+ if __name__ == "__main__":
114
+ import inspect
115
+ failures = []
116
+ cases = [(n, f) for n, f in sorted(globals().items())
117
+ if n.startswith("test_") and callable(f)
118
+ and not inspect.signature(f).parameters]
119
+ for name, fn in cases:
120
+ try:
121
+ fn()
122
+ print(f" PASS {name}")
123
+ except AssertionError:
124
+ print(f" FAIL {name}")
125
+ failures.append(name)
126
+ print(f"\n{'ALL PASS' if not failures else f'FAILED: {failures}'}"
127
+ f" — {len(cases) - len(failures)}/{len(cases)}")
128
+ sys.exit(1 if failures else 0)
@@ -0,0 +1,26 @@
1
+ """Fixture integration — pytest only (excluded from the bare-python runner)."""
2
+
3
+ import pytest
4
+
5
+ from lcb_gate.plugin import LcbHelper
6
+
7
+
8
+ def test_fixture_is_registered(lcb):
9
+ assert isinstance(lcb, LcbHelper)
10
+
11
+
12
+ def test_check_passes_clean_run(lcb):
13
+ res = lcb.check(lambda i: True, n=25, threshold=0.9)
14
+ assert res.passed
15
+
16
+
17
+ def test_check_fails_flaky_run():
18
+ helper = LcbHelper()
19
+ with pytest.raises(pytest.fail.Exception):
20
+ helper.check(lambda i: i % 2 == 0, n=20, threshold=0.9)
21
+
22
+
23
+ def test_check_better_fails_on_tie():
24
+ helper = LcbHelper()
25
+ with pytest.raises(pytest.fail.Exception):
26
+ helper.check_better(lambda s: 1.0, lambda s: 1.0, seeds=range(50))