repeatability-evals 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.
Files changed (28) hide show
  1. repeatability_evals-0.1.0/.github/workflows/ci.yml +17 -0
  2. repeatability_evals-0.1.0/.github/workflows/publish.yml +16 -0
  3. repeatability_evals-0.1.0/.gitignore +6 -0
  4. repeatability_evals-0.1.0/LICENSE +21 -0
  5. repeatability_evals-0.1.0/PKG-INFO +147 -0
  6. repeatability_evals-0.1.0/README.md +128 -0
  7. repeatability_evals-0.1.0/benchmarks/sampling_error.py +56 -0
  8. repeatability_evals-0.1.0/pyproject.toml +40 -0
  9. repeatability_evals-0.1.0/src/repeatability_evals/__init__.py +38 -0
  10. repeatability_evals-0.1.0/src/repeatability_evals/dataset.py +80 -0
  11. repeatability_evals-0.1.0/src/repeatability_evals/metrics.py +223 -0
  12. repeatability_evals-0.1.0/src/repeatability_evals/prompts.py +120 -0
  13. repeatability_evals-0.1.0/src/repeatability_evals/py.typed +0 -0
  14. repeatability_evals-0.1.0/src/repeatability_evals/sampling.py +102 -0
  15. repeatability_evals-0.1.0/src/repeatability_evals/similarity/__init__.py +14 -0
  16. repeatability_evals-0.1.0/src/repeatability_evals/similarity/base.py +85 -0
  17. repeatability_evals-0.1.0/src/repeatability_evals/similarity/embedding.py +56 -0
  18. repeatability_evals-0.1.0/src/repeatability_evals/similarity/judge.py +185 -0
  19. repeatability_evals-0.1.0/src/repeatability_evals/similarity/ranking.py +46 -0
  20. repeatability_evals-0.1.0/src/repeatability_evals/similarity/sets.py +53 -0
  21. repeatability_evals-0.1.0/src/repeatability_evals/similarity/text.py +114 -0
  22. repeatability_evals-0.1.0/src/repeatability_evals/stats.py +27 -0
  23. repeatability_evals-0.1.0/src/repeatability_evals/types.py +43 -0
  24. repeatability_evals-0.1.0/tests/test_dataset.py +67 -0
  25. repeatability_evals-0.1.0/tests/test_judge.py +177 -0
  26. repeatability_evals-0.1.0/tests/test_metrics.py +121 -0
  27. repeatability_evals-0.1.0/tests/test_sampling.py +66 -0
  28. repeatability_evals-0.1.0/tests/test_similarity.py +166 -0
@@ -0,0 +1,17 @@
1
+ name: CI
2
+ on:
3
+ push: { branches: [main] }
4
+ pull_request:
5
+ jobs:
6
+ test:
7
+ runs-on: ubuntu-latest
8
+ strategy:
9
+ matrix:
10
+ python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: actions/setup-python@v5
14
+ with: { python-version: "${{ matrix.python-version }}" }
15
+ - run: pip install -e ".[dev]"
16
+ - run: ruff check .
17
+ - run: pytest -q
@@ -0,0 +1,16 @@
1
+ name: Publish to PyPI
2
+ on:
3
+ release:
4
+ types: [published]
5
+ jobs:
6
+ publish:
7
+ runs-on: ubuntu-latest
8
+ environment: pypi
9
+ permissions:
10
+ id-token: write # PyPI trusted publishing; no API token needed
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: actions/setup-python@v5
14
+ with: { python-version: "3.12" }
15
+ - run: pip install build && python -m build
16
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Krishna Gogineni
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,147 @@
1
+ Metadata-Version: 2.5
2
+ Name: repeatability-evals
3
+ Version: 0.1.0
4
+ Summary: Consistency evals for non-deterministic systems: LLM generation, agentic retrieval, agent workflows.
5
+ Project-URL: Homepage, https://github.com/krishna-gogineni-765/repeatability-evals
6
+ Project-URL: Issues, https://github.com/krishna-gogineni-765/repeatability-evals/issues
7
+ Author: Krishna Gogineni
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: agents,consistency,evaluation,llm,llm-as-judge,non-determinism,rag,reliability,repeatability,retrieval
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Typing :: Typed
14
+ Requires-Python: >=3.9
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest>=7; extra == 'dev'
17
+ Requires-Dist: ruff>=0.5; extra == 'dev'
18
+ Description-Content-Type: text/markdown
19
+
20
+ # repeatability-evals
21
+
22
+ **Consistency evals for non-deterministic systems: LLM generation, agentic retrieval, agent workflows.**
23
+
24
+ Composable tools for measuring **how consistent an LLM system is with itself**: the same
25
+ input run several times, or an input and its paraphrases, and how much the outputs agree.
26
+
27
+ Most eval libraries score one run for quality. A system can average 90% on quality and still
28
+ give a customer a different answer every time they ask. This package measures that spread.
29
+
30
+ It is deliberately **unaware of what you are comparing**. It has no notion of "answers",
31
+ "retrieval" or "agents": it compares *items*. You choose the items and the similarity:
32
+
33
+ | You have N of ... | Use a similarity such as |
34
+ |--------------------------------------|--------------------------------------------------|
35
+ | final answers (free text) | `LLMJudge`, `EmbeddingCosine`, `RougeL` |
36
+ | retrieved chunk IDs | `JaccardSimilarity` |
37
+ | retrieved chunk text | `NgramJaccard` (robust to re-chunking) |
38
+ | ranked result lists | `RankBiasedOverlap` |
39
+ | agent tool-call traces | `EditSimilarity` / `ExactMatch` on a trace string|
40
+ | anything else | subclass `Similarity`, or pass a plain function |
41
+
42
+ Consistency is **not** correctness: a system that is consistently wrong scores 1.0. Pair this
43
+ with an accuracy metric.
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ pip install repeatability-evals # pure standard library, no required dependencies
49
+ ```
50
+
51
+ ## Quickstart
52
+
53
+ ```python
54
+ from repeatability_evals import (
55
+ PairwiseConsistency, JaccardSimilarity, NgramJaccard, LLMJudge, evaluate_groups,
56
+ )
57
+
58
+ # 1. Retrieval: did the 5 runs fetch the same chunks?
59
+ ids = PairwiseConsistency(JaccardSimilarity())
60
+ print(ids.evaluate([["a", "b", "c"], ["a", "b", "d"], ["a", "b", "c"]]).score)
61
+
62
+ # 2. Same evidence, different chunk boundaries?
63
+ text = PairwiseConsistency(NgramJaccard(n=3))
64
+ print(text.evaluate([["backup jobs run nightly"], ["backup jobs", "run nightly"]]).score)
65
+
66
+ # 3. Answers: an LLM judge scores semantic equivalence. Bring any `str -> str` function.
67
+ def complete(prompt: str) -> str: ... # your model call
68
+ answers = PairwiseConsistency(LLMJudge(complete), agreement_threshold=0.75)
69
+ r = answers.evaluate(["Retention is 30 days.", "30 days.", "Retention is 90 days."],
70
+ context={"query": "How long are backups retained?"})
71
+ print(r.score, r.details["agreement_rate"], [p.reason for p in r.pairs])
72
+
73
+ # 4. A whole dataset: one group per question, with a bootstrap confidence interval.
74
+ report = evaluate_groups({"q1": runs_q1, "q2": runs_q2}, ids)
75
+ print(report.score, report.ci, report.worst(3))
76
+ ```
77
+
78
+ ## Design
79
+
80
+ Three small pieces, each replaceable:
81
+
82
+ * **`Similarity`**: compare two items, return a score in `[0, 1]` (optionally with a raw value
83
+ and a rationale). Built in: set overlap (`Jaccard`, `Dice`, `OverlapCoefficient`), text
84
+ (`ExactMatch`, `NgramJaccard`, `RougeL`, `EditSimilarity`), ranking (`RankBiasedOverlap`),
85
+ embeddings (`EmbeddingCosine`, bring your own embed function), and `RubricJudge` and `LLMJudge`.
86
+ * **`PairSampler`**: which pairs of the N items to compare (see below).
87
+ * **`ConsistencyMetric`**: combines them. `PairwiseConsistency` averages similarity over
88
+ sampled pairs; `ClusterConsistency` groups items into "same answer" clusters and reports the
89
+ largest cluster's share plus the entropy of the partition; `TotalAgreement` is the
90
+ all-N-identical rate.
91
+
92
+ Everything has an async twin (`aevaluate`, `aevaluate_groups`) with bounded concurrency, which
93
+ matters when the similarity is an LLM call.
94
+
95
+ ### Sampling instead of all pairs
96
+
97
+ All pairs cost N(N-1)/2 comparisons: 10 for 5 runs, 45 for 10. The default `CycleSampler`
98
+ compares a random cycle through the runs, so N pairs, and every run is compared at least twice.
99
+ It is seeded and reproducible. `AllPairs()` gives the exact value; `RandomPairs(k)` and
100
+ `AnchorPairs()` (every run against one reference) are also available. `benchmarks/sampling_error.py`
101
+ measures the error this introduces on synthetic data so you can see the trade-off for your N.
102
+
103
+ ### The LLM judges
104
+
105
+ `RubricJudge` is the recommended judge for free-text outputs. One call scores five dimensions
106
+ of agreement (1-5 each): `core_facts`, `specifics` (names, numbers, dates), `supporting_detail`,
107
+ `stance` (answer vs hedge vs refuse) and `format`. The headline score is their weighted mean
108
+ (core facts count most), and `PairwiseConsistency` reports the mean per dimension in
109
+ `details["dimensions"]`, so you can see *what* varies between runs: a system whose facts are
110
+ stable but whose formatting drifts looks very different from one that changes its answer.
111
+ Pass your own `Dimension`s to add, remove or reweight (e.g. for agent traces).
112
+
113
+ ```python
114
+ from repeatability_evals import RubricJudge, PairwiseConsistency
115
+ m = PairwiseConsistency(RubricJudge(complete))
116
+ r = m.evaluate(runs, context={"query": question})
117
+ print(r.score, r.details["dimensions"]) # {'core_facts': 0.95, 'format': 0.4, ...}
118
+ ```
119
+
120
+ `LLMJudge` is the single-score version (one 1-5 semantic-equivalence rating). Both take any completion function, so it works with any provider or a local model. The
121
+ default rubrics (1-5, normalised to `[0, 1]`) are in `repeatability_evals.prompts`. `symmetric=True`
122
+ judges both orderings to cancel position bias at twice the cost. **Validate any judge against
123
+ human ratings on your own data before relying on it**; agreement depends on the domain and the
124
+ judge model.
125
+
126
+ ## Writing your own similarity
127
+
128
+ ```python
129
+ from repeatability_evals import Similarity, PairwiseConsistency
130
+
131
+ class SameFirstSentence(Similarity):
132
+ name = "same_first_sentence"
133
+ def compute(self, a, b, *, context=None):
134
+ return float(a.split(".")[0] == b.split(".")[0])
135
+
136
+ PairwiseConsistency(SameFirstSentence())
137
+ ```
138
+
139
+ or just `PairwiseConsistency(lambda a, b: ...)`.
140
+
141
+ ## Status
142
+
143
+ Early (0.1). The API may change before 1.0.
144
+
145
+ ## License
146
+
147
+ MIT
@@ -0,0 +1,128 @@
1
+ # repeatability-evals
2
+
3
+ **Consistency evals for non-deterministic systems: LLM generation, agentic retrieval, agent workflows.**
4
+
5
+ Composable tools for measuring **how consistent an LLM system is with itself**: the same
6
+ input run several times, or an input and its paraphrases, and how much the outputs agree.
7
+
8
+ Most eval libraries score one run for quality. A system can average 90% on quality and still
9
+ give a customer a different answer every time they ask. This package measures that spread.
10
+
11
+ It is deliberately **unaware of what you are comparing**. It has no notion of "answers",
12
+ "retrieval" or "agents": it compares *items*. You choose the items and the similarity:
13
+
14
+ | You have N of ... | Use a similarity such as |
15
+ |--------------------------------------|--------------------------------------------------|
16
+ | final answers (free text) | `LLMJudge`, `EmbeddingCosine`, `RougeL` |
17
+ | retrieved chunk IDs | `JaccardSimilarity` |
18
+ | retrieved chunk text | `NgramJaccard` (robust to re-chunking) |
19
+ | ranked result lists | `RankBiasedOverlap` |
20
+ | agent tool-call traces | `EditSimilarity` / `ExactMatch` on a trace string|
21
+ | anything else | subclass `Similarity`, or pass a plain function |
22
+
23
+ Consistency is **not** correctness: a system that is consistently wrong scores 1.0. Pair this
24
+ with an accuracy metric.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pip install repeatability-evals # pure standard library, no required dependencies
30
+ ```
31
+
32
+ ## Quickstart
33
+
34
+ ```python
35
+ from repeatability_evals import (
36
+ PairwiseConsistency, JaccardSimilarity, NgramJaccard, LLMJudge, evaluate_groups,
37
+ )
38
+
39
+ # 1. Retrieval: did the 5 runs fetch the same chunks?
40
+ ids = PairwiseConsistency(JaccardSimilarity())
41
+ print(ids.evaluate([["a", "b", "c"], ["a", "b", "d"], ["a", "b", "c"]]).score)
42
+
43
+ # 2. Same evidence, different chunk boundaries?
44
+ text = PairwiseConsistency(NgramJaccard(n=3))
45
+ print(text.evaluate([["backup jobs run nightly"], ["backup jobs", "run nightly"]]).score)
46
+
47
+ # 3. Answers: an LLM judge scores semantic equivalence. Bring any `str -> str` function.
48
+ def complete(prompt: str) -> str: ... # your model call
49
+ answers = PairwiseConsistency(LLMJudge(complete), agreement_threshold=0.75)
50
+ r = answers.evaluate(["Retention is 30 days.", "30 days.", "Retention is 90 days."],
51
+ context={"query": "How long are backups retained?"})
52
+ print(r.score, r.details["agreement_rate"], [p.reason for p in r.pairs])
53
+
54
+ # 4. A whole dataset: one group per question, with a bootstrap confidence interval.
55
+ report = evaluate_groups({"q1": runs_q1, "q2": runs_q2}, ids)
56
+ print(report.score, report.ci, report.worst(3))
57
+ ```
58
+
59
+ ## Design
60
+
61
+ Three small pieces, each replaceable:
62
+
63
+ * **`Similarity`**: compare two items, return a score in `[0, 1]` (optionally with a raw value
64
+ and a rationale). Built in: set overlap (`Jaccard`, `Dice`, `OverlapCoefficient`), text
65
+ (`ExactMatch`, `NgramJaccard`, `RougeL`, `EditSimilarity`), ranking (`RankBiasedOverlap`),
66
+ embeddings (`EmbeddingCosine`, bring your own embed function), and `RubricJudge` and `LLMJudge`.
67
+ * **`PairSampler`**: which pairs of the N items to compare (see below).
68
+ * **`ConsistencyMetric`**: combines them. `PairwiseConsistency` averages similarity over
69
+ sampled pairs; `ClusterConsistency` groups items into "same answer" clusters and reports the
70
+ largest cluster's share plus the entropy of the partition; `TotalAgreement` is the
71
+ all-N-identical rate.
72
+
73
+ Everything has an async twin (`aevaluate`, `aevaluate_groups`) with bounded concurrency, which
74
+ matters when the similarity is an LLM call.
75
+
76
+ ### Sampling instead of all pairs
77
+
78
+ All pairs cost N(N-1)/2 comparisons: 10 for 5 runs, 45 for 10. The default `CycleSampler`
79
+ compares a random cycle through the runs, so N pairs, and every run is compared at least twice.
80
+ It is seeded and reproducible. `AllPairs()` gives the exact value; `RandomPairs(k)` and
81
+ `AnchorPairs()` (every run against one reference) are also available. `benchmarks/sampling_error.py`
82
+ measures the error this introduces on synthetic data so you can see the trade-off for your N.
83
+
84
+ ### The LLM judges
85
+
86
+ `RubricJudge` is the recommended judge for free-text outputs. One call scores five dimensions
87
+ of agreement (1-5 each): `core_facts`, `specifics` (names, numbers, dates), `supporting_detail`,
88
+ `stance` (answer vs hedge vs refuse) and `format`. The headline score is their weighted mean
89
+ (core facts count most), and `PairwiseConsistency` reports the mean per dimension in
90
+ `details["dimensions"]`, so you can see *what* varies between runs: a system whose facts are
91
+ stable but whose formatting drifts looks very different from one that changes its answer.
92
+ Pass your own `Dimension`s to add, remove or reweight (e.g. for agent traces).
93
+
94
+ ```python
95
+ from repeatability_evals import RubricJudge, PairwiseConsistency
96
+ m = PairwiseConsistency(RubricJudge(complete))
97
+ r = m.evaluate(runs, context={"query": question})
98
+ print(r.score, r.details["dimensions"]) # {'core_facts': 0.95, 'format': 0.4, ...}
99
+ ```
100
+
101
+ `LLMJudge` is the single-score version (one 1-5 semantic-equivalence rating). Both take any completion function, so it works with any provider or a local model. The
102
+ default rubrics (1-5, normalised to `[0, 1]`) are in `repeatability_evals.prompts`. `symmetric=True`
103
+ judges both orderings to cancel position bias at twice the cost. **Validate any judge against
104
+ human ratings on your own data before relying on it**; agreement depends on the domain and the
105
+ judge model.
106
+
107
+ ## Writing your own similarity
108
+
109
+ ```python
110
+ from repeatability_evals import Similarity, PairwiseConsistency
111
+
112
+ class SameFirstSentence(Similarity):
113
+ name = "same_first_sentence"
114
+ def compute(self, a, b, *, context=None):
115
+ return float(a.split(".")[0] == b.split(".")[0])
116
+
117
+ PairwiseConsistency(SameFirstSentence())
118
+ ```
119
+
120
+ or just `PairwiseConsistency(lambda a, b: ...)`.
121
+
122
+ ## Status
123
+
124
+ Early (0.1). The API may change before 1.0.
125
+
126
+ ## License
127
+
128
+ MIT
@@ -0,0 +1,56 @@
1
+ """Estimate how much error pair subsampling adds versus scoring all pairs.
2
+
3
+ Synthetic by default; pass --runs-file (JSON: {question: [answer, ...]}) to use real runs
4
+ with ExactMatch-style token overlap (NgramJaccard) as a cheap stand-in for a judge.
5
+ """
6
+ import argparse
7
+ import itertools
8
+ import json
9
+ import random
10
+ import statistics
11
+
12
+ from repeatability_evals import CycleSampler, NgramJaccard
13
+
14
+
15
+ def synthetic(n_q, n_runs, rng):
16
+ out = []
17
+ for _ in range(n_q):
18
+ p = rng.choice([0.0, 0.1, 0.3, 0.6]) # per-question divergence
19
+ base = [rng.random() for _ in range(n_runs)]
20
+ out.append([0.0 if rng.random() > p else b for b in base])
21
+ return out
22
+
23
+
24
+ def pair_score_synth(a, b):
25
+ return 1.0 - min(1.0, abs(a - b) * 3)
26
+
27
+
28
+ def main():
29
+ ap = argparse.ArgumentParser()
30
+ ap.add_argument("--runs", type=int, default=5)
31
+ ap.add_argument("--questions", type=int, default=300)
32
+ ap.add_argument("--runs-file")
33
+ ap.add_argument("--seed", type=int, default=0)
34
+ a = ap.parse_args()
35
+ rng = random.Random(a.seed)
36
+ if a.runs_file:
37
+ groups = list(json.load(open(a.runs_file)).values())
38
+ sim = NgramJaccard(2)
39
+ score = lambda x, y: sim(x, y) # noqa: E731
40
+ else:
41
+ groups = synthetic(a.questions, a.runs, rng)
42
+ score = pair_score_synth
43
+ for budget in (None, 7, 8):
44
+ errs = []
45
+ for g in groups:
46
+ n = len(g)
47
+ full = statistics.mean(score(g[i], g[j]) for i, j in itertools.combinations(range(n), 2))
48
+ ps = CycleSampler(budget=budget, seed=rng.randrange(10**6)).sample(n)
49
+ sub = statistics.mean(score(g[i], g[j]) for i, j in ps)
50
+ errs.append(sub - full)
51
+ print(f"budget={budget or 'N'}: mean_abs_err={statistics.mean(map(abs, errs)):.3f} "
52
+ f"bias={statistics.mean(errs):+.4f} max_abs_err={max(map(abs, errs)):.3f}")
53
+
54
+
55
+ if __name__ == "__main__":
56
+ main()
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "repeatability-evals"
7
+ version = "0.1.0"
8
+ description = "Consistency evals for non-deterministic systems: LLM generation, agentic retrieval, agent workflows."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "Krishna Gogineni" }]
13
+ keywords = ["llm", "evaluation", "consistency", "repeatability", "non-determinism", "rag", "retrieval", "agents", "reliability", "llm-as-judge"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Typing :: Typed",
18
+ ]
19
+ dependencies = [] # core is pure standard library
20
+
21
+ [project.optional-dependencies]
22
+ dev = ["pytest>=7", "ruff>=0.5"]
23
+
24
+ [tool.hatch.build.targets.wheel]
25
+ packages = ["src/repeatability_evals"]
26
+
27
+ [tool.pytest.ini_options]
28
+ testpaths = ["tests"]
29
+
30
+ [tool.ruff]
31
+ line-length = 110
32
+ src = ["src", "tests"]
33
+
34
+ [tool.ruff.lint]
35
+ select = ["E", "F", "I", "B", "UP"]
36
+ ignore = ["UP006", "UP007", "UP035", "UP045"] # keep typing.Optional/List for py3.9 readability
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/krishna-gogineni-765/repeatability-evals"
40
+ Issues = "https://github.com/krishna-gogineni-765/repeatability-evals/issues"
@@ -0,0 +1,38 @@
1
+ """repeatability-evals: consistency evals for non-deterministic LLM systems."""
2
+ from .dataset import DatasetResult, aevaluate_groups, evaluate_groups
3
+ from .metrics import ClusterConsistency, ConsistencyMetric, PairwiseConsistency, TotalAgreement
4
+ from .prompts import DEFAULT_DIMENSIONS, SEMANTIC_EQUIVALENCE_PROMPT, Dimension, build_rubric_prompt
5
+ from .sampling import AllPairs, AnchorPairs, CycleSampler, PairSampler, RandomPairs, cycle_pairs
6
+ from .similarity import (
7
+ DiceSimilarity,
8
+ EditSimilarity,
9
+ EmbeddingCosine,
10
+ ExactMatch,
11
+ FunctionSimilarity,
12
+ JaccardSimilarity,
13
+ JudgeParseError,
14
+ LLMJudge,
15
+ NgramJaccard,
16
+ OverlapCoefficient,
17
+ RankBiasedOverlap,
18
+ RougeL,
19
+ RubricJudge,
20
+ Similarity,
21
+ as_similarity,
22
+ )
23
+ from .stats import bootstrap_ci
24
+ from .types import ConsistencyResult, PairScore, SimilarityResult
25
+
26
+ __version__ = "0.1.0"
27
+
28
+ __all__ = [
29
+ "ConsistencyMetric", "PairwiseConsistency", "ClusterConsistency", "TotalAgreement",
30
+ "evaluate_groups", "aevaluate_groups", "DatasetResult", "bootstrap_ci",
31
+ "Similarity", "FunctionSimilarity", "as_similarity",
32
+ "JaccardSimilarity", "DiceSimilarity", "OverlapCoefficient",
33
+ "ExactMatch", "NgramJaccard", "RougeL", "EditSimilarity",
34
+ "RankBiasedOverlap", "EmbeddingCosine", "LLMJudge", "RubricJudge", "JudgeParseError",
35
+ "Dimension", "DEFAULT_DIMENSIONS", "build_rubric_prompt",
36
+ "PairSampler", "AllPairs", "CycleSampler", "RandomPairs", "AnchorPairs", "cycle_pairs",
37
+ "ConsistencyResult", "PairScore", "SimilarityResult", "SEMANTIC_EQUIVALENCE_PROMPT",
38
+ ]
@@ -0,0 +1,80 @@
1
+ """Run a metric over many groups (one group = the N runs of one input) and summarise."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Dict, Hashable, List, Mapping, Optional, Sequence, Tuple
7
+
8
+ from .metrics import ConsistencyMetric
9
+ from .stats import bootstrap_ci, mean
10
+ from .types import ConsistencyResult
11
+
12
+
13
+ @dataclass
14
+ class DatasetResult:
15
+ metric: str
16
+ score: float # mean of per-group scores
17
+ n_groups: int
18
+ ci: Optional[Tuple[float, float]] = None # bootstrap CI of ``score``
19
+ per_group: Dict[Hashable, ConsistencyResult] = field(default_factory=dict)
20
+
21
+ def worst(self, k: int = 5) -> List[Tuple[Hashable, ConsistencyResult]]:
22
+ """The ``k`` least consistent groups: where to look first when debugging."""
23
+ return sorted(self.per_group.items(), key=lambda kv: kv[1].score)[:k]
24
+
25
+ def to_records(self) -> List[Dict[str, Any]]:
26
+ return [
27
+ {"group": key, "score": r.score, "min_score": r.min_score,
28
+ "n_items": r.n_items, "n_pairs": r.n_pairs, **{f"d_{k}": v for k, v in r.details.items()
29
+ if isinstance(v, (int, float, str, bool))}}
30
+ for key, r in self.per_group.items()
31
+ ]
32
+
33
+
34
+ def _summarise(
35
+ metric: ConsistencyMetric, results: Dict[Hashable, ConsistencyResult],
36
+ ci: bool, n_boot: int, seed: int,
37
+ ) -> DatasetResult:
38
+ if not results:
39
+ raise ValueError("no groups to evaluate")
40
+ scores = [r.score for r in results.values()]
41
+ interval = bootstrap_ci(scores, n_boot=n_boot, seed=seed) if ci else None
42
+ return DatasetResult(metric.name, mean(scores), len(results), interval, results)
43
+
44
+
45
+ def evaluate_groups(
46
+ groups: Mapping[Hashable, Sequence[Any]],
47
+ metric: ConsistencyMetric,
48
+ *,
49
+ contexts: Optional[Mapping[Hashable, Mapping[str, Any]]] = None,
50
+ ci: bool = True,
51
+ n_boot: int = 2000,
52
+ seed: int = 0,
53
+ ) -> DatasetResult:
54
+ """``groups`` maps a group key (e.g. a question ID) to its N items; ``contexts`` optionally
55
+ maps the same keys to per-group context such as ``{"query": "..."}`` for an LLM judge."""
56
+ results = {
57
+ key: metric.evaluate(items, context=(contexts or {}).get(key))
58
+ for key, items in groups.items()
59
+ }
60
+ return _summarise(metric, results, ci, n_boot, seed)
61
+
62
+
63
+ async def aevaluate_groups(
64
+ groups: Mapping[Hashable, Sequence[Any]],
65
+ metric: ConsistencyMetric,
66
+ *,
67
+ contexts: Optional[Mapping[Hashable, Mapping[str, Any]]] = None,
68
+ concurrency: int = 4,
69
+ ci: bool = True,
70
+ n_boot: int = 2000,
71
+ seed: int = 0,
72
+ ) -> DatasetResult:
73
+ sem = asyncio.Semaphore(concurrency)
74
+
75
+ async def one(key: Hashable, items: Sequence[Any]) -> Tuple[Hashable, ConsistencyResult]:
76
+ async with sem:
77
+ return key, await metric.aevaluate(items, context=(contexts or {}).get(key))
78
+
79
+ pairs = await asyncio.gather(*(one(k, v) for k, v in groups.items()))
80
+ return _summarise(metric, dict(pairs), ci, n_boot, seed)