lcb-gate 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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
+ ]
lcb_gate/gate.py ADDED
@@ -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)
lcb_gate/plugin.py ADDED
@@ -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()
lcb_gate/stats.py ADDED
@@ -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,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,9 @@
1
+ lcb_gate/__init__.py,sha256=a0ffqTt4wVwnfy3PlIFWlA4jpD7J1KVhbtosWfwnC1Y,641
2
+ lcb_gate/gate.py,sha256=b9ee609Fwzi2z1rjVfG48FxL5OWlgs7VaNJuGRRsuqg,3465
3
+ lcb_gate/plugin.py,sha256=e0uQCocuVGkQSMTR-nu8swIPpnciaPsxy-PDEGZ7OF0,1159
4
+ lcb_gate/stats.py,sha256=rGaOd7eb__Spv94yzLtyNrq6DVk_AXOb5KK_PvVlce0,1390
5
+ lcb_gate-0.1.0.dist-info/METADATA,sha256=07NXrv5ppPZkdGHW7o3EoVOcMtTxsjslPQf4l4kW1X4,5827
6
+ lcb_gate-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
7
+ lcb_gate-0.1.0.dist-info/entry_points.txt,sha256=cl6VESqUUyDfDQyeNf1Yz6os8hSWpfs1VbaYrbuL5zk,38
8
+ lcb_gate-0.1.0.dist-info/licenses/LICENSE,sha256=76kJpx8qwE8s8qdKjsnngPtznV3WasZ54ed4WWW2y7o,1065
9
+ lcb_gate-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ lcb_gate = lcb_gate.plugin
@@ -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.