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.
- lcb_gate-0.1.0/.gitattributes +1 -0
- lcb_gate-0.1.0/.github/workflows/ci.yml +31 -0
- lcb_gate-0.1.0/.github/workflows/release.yml +44 -0
- lcb_gate-0.1.0/.gitignore +10 -0
- lcb_gate-0.1.0/CHANGELOG.md +12 -0
- lcb_gate-0.1.0/LICENSE +21 -0
- lcb_gate-0.1.0/PKG-INFO +160 -0
- lcb_gate-0.1.0/README.md +133 -0
- lcb_gate-0.1.0/examples/demo.py +69 -0
- lcb_gate-0.1.0/pyproject.toml +52 -0
- lcb_gate-0.1.0/src/lcb_gate/__init__.py +22 -0
- lcb_gate-0.1.0/src/lcb_gate/gate.py +90 -0
- lcb_gate-0.1.0/src/lcb_gate/plugin.py +36 -0
- lcb_gate-0.1.0/src/lcb_gate/stats.py +38 -0
- lcb_gate-0.1.0/tests/conftest.py +19 -0
- lcb_gate-0.1.0/tests/test_lcb.py +128 -0
- lcb_gate-0.1.0/tests/test_plugin.py +26 -0
|
@@ -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,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.
|
lcb_gate-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](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
|
lcb_gate-0.1.0/README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# lcb-gate
|
|
2
|
+
|
|
3
|
+
[](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))
|