repeatability-evals 0.1.0__tar.gz → 0.3.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 (42) hide show
  1. {repeatability_evals-0.1.0 → repeatability_evals-0.3.0}/.github/workflows/ci.yml +0 -0
  2. {repeatability_evals-0.1.0 → repeatability_evals-0.3.0}/.github/workflows/publish.yml +0 -0
  3. {repeatability_evals-0.1.0 → repeatability_evals-0.3.0}/.gitignore +1 -0
  4. repeatability_evals-0.3.0/CHANGELOG.md +50 -0
  5. {repeatability_evals-0.1.0 → repeatability_evals-0.3.0}/LICENSE +0 -0
  6. repeatability_evals-0.3.0/PKG-INFO +178 -0
  7. repeatability_evals-0.3.0/README.md +159 -0
  8. {repeatability_evals-0.1.0 → repeatability_evals-0.3.0}/benchmarks/sampling_error.py +0 -0
  9. repeatability_evals-0.3.0/examples/compare_versions_demo.py +57 -0
  10. {repeatability_evals-0.1.0 → repeatability_evals-0.3.0}/pyproject.toml +4 -1
  11. {repeatability_evals-0.1.0 → repeatability_evals-0.3.0}/src/repeatability_evals/__init__.py +20 -3
  12. repeatability_evals-0.3.0/src/repeatability_evals/__main__.py +3 -0
  13. repeatability_evals-0.3.0/src/repeatability_evals/cli.py +137 -0
  14. repeatability_evals-0.3.0/src/repeatability_evals/compare.py +488 -0
  15. repeatability_evals-0.3.0/src/repeatability_evals/dataset.py +118 -0
  16. {repeatability_evals-0.1.0 → repeatability_evals-0.3.0}/src/repeatability_evals/metrics.py +109 -17
  17. {repeatability_evals-0.1.0 → repeatability_evals-0.3.0}/src/repeatability_evals/prompts.py +19 -7
  18. {repeatability_evals-0.1.0 → repeatability_evals-0.3.0}/src/repeatability_evals/py.typed +0 -0
  19. {repeatability_evals-0.1.0 → repeatability_evals-0.3.0}/src/repeatability_evals/sampling.py +47 -2
  20. {repeatability_evals-0.1.0 → repeatability_evals-0.3.0}/src/repeatability_evals/similarity/__init__.py +10 -2
  21. {repeatability_evals-0.1.0 → repeatability_evals-0.3.0}/src/repeatability_evals/similarity/base.py +28 -5
  22. repeatability_evals-0.3.0/src/repeatability_evals/similarity/embedding.py +116 -0
  23. repeatability_evals-0.3.0/src/repeatability_evals/similarity/judge.py +330 -0
  24. {repeatability_evals-0.1.0 → repeatability_evals-0.3.0}/src/repeatability_evals/similarity/ranking.py +0 -0
  25. {repeatability_evals-0.1.0 → repeatability_evals-0.3.0}/src/repeatability_evals/similarity/sets.py +0 -0
  26. {repeatability_evals-0.1.0 → repeatability_evals-0.3.0}/src/repeatability_evals/similarity/text.py +0 -0
  27. repeatability_evals-0.3.0/src/repeatability_evals/stats.py +71 -0
  28. {repeatability_evals-0.1.0 → repeatability_evals-0.3.0}/src/repeatability_evals/types.py +0 -0
  29. repeatability_evals-0.3.0/tests/test_cli.py +32 -0
  30. repeatability_evals-0.3.0/tests/test_compare.py +148 -0
  31. {repeatability_evals-0.1.0 → repeatability_evals-0.3.0}/tests/test_dataset.py +0 -0
  32. {repeatability_evals-0.1.0 → repeatability_evals-0.3.0}/tests/test_judge.py +0 -0
  33. {repeatability_evals-0.1.0 → repeatability_evals-0.3.0}/tests/test_metrics.py +0 -0
  34. repeatability_evals-0.3.0/tests/test_robustness.py +310 -0
  35. {repeatability_evals-0.1.0 → repeatability_evals-0.3.0}/tests/test_sampling.py +0 -0
  36. {repeatability_evals-0.1.0 → repeatability_evals-0.3.0}/tests/test_similarity.py +0 -0
  37. repeatability_evals-0.1.0/PKG-INFO +0 -147
  38. repeatability_evals-0.1.0/README.md +0 -128
  39. repeatability_evals-0.1.0/src/repeatability_evals/dataset.py +0 -80
  40. repeatability_evals-0.1.0/src/repeatability_evals/similarity/embedding.py +0 -56
  41. repeatability_evals-0.1.0/src/repeatability_evals/similarity/judge.py +0 -185
  42. repeatability_evals-0.1.0/src/repeatability_evals/stats.py +0 -27
@@ -4,3 +4,4 @@ __pycache__/
4
4
  .pytest_cache/
5
5
  .ruff_cache/
6
6
  dist/
7
+ examples/demo_report.md
@@ -0,0 +1,50 @@
1
+ # Changelog
2
+
3
+ ## 0.3.0
4
+
5
+ ### Fixed
6
+ - **Dataset means no longer count single-run groups.** Groups with fewer than 2 items were scored
7
+ 1.0 and averaged in, inflating `evaluate_groups` results. They are now excluded and listed in
8
+ `DatasetResult.degenerate`.
9
+ - **`acompare_versions(concurrency=N)` is a real global cap.** Previously each input and each metric
10
+ had its own semaphore, so `concurrency=4` could issue ~24 simultaneous judge calls.
11
+ - **Judge prompts are filled in one pass.** A response containing `{item_b}` could pull the other
12
+ response into its own slot. Responses are now also enclosed in tags with an instruction to
13
+ treat them as data (`render_prompt` is public).
14
+ - **Score parsing ignores scale text.** `"Score (1-5): 4"` parsed as 1 and `"2 out of 5"` could
15
+ parse as 5. JSON is now found even after prose containing braces, and rubric scores may be
16
+ nested under `"scores"` or top level.
17
+ - `EmbeddingCosine` cache was not thread-safe under the async metrics (possible `KeyError`).
18
+ - `TotalAgreement` crashed on unhashable items (dicts, lists); they are compared as canonical JSON.
19
+ - `cycle_pairs` with a truncated budget now always covers `min(N, 2 * budget)` items (odd N missed
20
+ one).
21
+ - `__version__` is read from package metadata (0.2.0 on PyPI reported 0.1.0).
22
+
23
+ ### Added
24
+ - **Noise-aware per-input flags in `compare_versions`.** An input now counts as changed only if
25
+ its drift exceeds `tolerance` *and* a permutation test on its pair scores (cross-version pairs
26
+ less alike than within-version pairs) gives `p_value <= alpha` (default 0.2; `alpha=1.0`
27
+ restores the old drift-only rule). On simulated noisy-but-unchanged systems (30% of runs
28
+ differ, 4 runs per side) the share of inputs flagged by chance drops from ~21% to ~5%, and in
29
+ `examples/compare_versions_demo.py` the flagged set now matches the 36 truly changed inputs
30
+ exactly (previously 5 false positives). The dataset-level verdict is unchanged in method.
31
+ `InputComparison.p_value` and CLI `--alpha` expose it.
32
+ - `on_error="skip"` on `PairwiseConsistency`, `evaluate_groups`, `aevaluate_groups`,
33
+ `compare_versions`, `acompare_versions` and the CLI (`--on-error skip`): failing comparisons are
34
+ dropped and reported instead of aborting a long run. `AllComparisonsFailed` when nothing is left.
35
+ - Judges: `retries` (default 2) for unparsable replies, `skip_identical` (default on: identical
36
+ outputs score 1.0 with no model call) and an LRU `cache_size` (default 10,000). Symmetric async
37
+ judging runs both orders concurrently.
38
+ - `Similarity.prepare` / `aprepare` hook, called with every item of a group; `EmbeddingCosine` uses
39
+ it to embed a group in one batch, and accepts an async `aembed`.
40
+ - `call_limit(n)` context manager and `aevaluate_groups(max_calls=...)` for global rate limiting.
41
+ - `FunctionSimilarity` passes `context` to functions that accept it.
42
+ - CLI reads JSON Lines (`{"id", "output", "query"}` per line).
43
+ - HTML reports are now structured pages (tables, headings, light/dark) instead of preformatted
44
+ markdown.
45
+
46
+ ### Changed
47
+ - CLI exit codes: `0` no meaningful change, `3` inconclusive, `4` changed (were 0/1/2, which
48
+ collided with Python and argparse error codes).
49
+ - Judge prompts wrap the input and responses in `<input>`, `<response_a>`, `<response_b>` tags.
50
+ The rubric wording is unchanged.
@@ -0,0 +1,178 @@
1
+ Metadata-Version: 2.5
2
+ Name: repeatability-evals
3
+ Version: 0.3.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
+ Most evals ask *"is the output correct?"* and need gold labels to answer. This library asks
25
+ two questions that are extremely important in enterprise settings and need **no gold labels**:
26
+
27
+ 1. **Is my system consistent/repeatable with itself?** Same input, N runs: do the answers, the retrieved
28
+ documents, the tool-call traces agree?
29
+ 2. **Did anything user facing change when I changed something in the backend?** Model upgrade, prompt edit, new index,
30
+ framework bump: which inputs behave differently, *beyond the system's own run-to-run noise*,
31
+ and *how* (facts? numbers? format? refusals?).
32
+
33
+ ## Why it matters
34
+
35
+ | If you are... | You can... |
36
+ |---|---|
37
+ | **a platform or model provider** shipping an update | run it offline on a regression set and see which customer-style workflows would shift before you release |
38
+ | **an enterprise on a vendor's LLM or agent platform** | run it yourself, on your own data and cluster, to decide whether an upstream upgrade is safe for the workflows you built. Nothing leaves your environment, and `include_text=False` keeps raw text out of reports |
39
+ | **building an agent, RAG or harness product** | track whether retrieval and answers stay stable across runs, and catch silent drift from non-deterministic infrastructure |
40
+ | **without labelled data** | still get a signal: consistency needs only outputs, not answers |
41
+
42
+
43
+ ## Usage pattern 1: See what changed
44
+
45
+ ```python
46
+ from repeatability_evals import compare_versions, RubricJudge
47
+
48
+ # {input_id: [output of run 1, run 2, run 3, ...]} for each version. No labels.
49
+ report = compare_versions(v1_runs, v2_runs, RubricJudge(my_llm), names=("v1", "v2"))
50
+ print(report.verdict) # no_meaningful_change | changed | inconclusive
51
+ report.save("report.md") # also .html / .json
52
+ ```
53
+
54
+ ```
55
+ # v1 vs v2: consistency report
56
+ CHANGED: the candidate's outputs differ from the baseline's by more than run-to-run noise explains.
57
+ 120 inputs compared; 30% changed beyond the noise floor.
58
+
59
+ | v1 vs itself (noise floor) | 0.925 |
60
+ | v2 vs itself | 0.948 |
61
+ | v1 vs v2 | 0.850 |
62
+ | drift (expected - cross; 0 = no change) | +0.087 (95% CI +0.060 to +0.116) |
63
+
64
+ ## What changed
65
+ | format | +0.200 |
66
+ | specifics | +0.115 |
67
+ | supporting_detail | +0.100 |
68
+ | core_facts | +0.057 |
69
+ Changed inputs by main driver: 21 mainly in `format`, 15 mainly in `specifics`.
70
+
71
+ ### `q000`: drift +0.62 (cross 0.38 vs expected 1.00, p = 0.001)
72
+ - Main driver: `specifics`
73
+ - Judge: Retention period differs.
74
+ - v1: Retention for gold is 30 days.
75
+ - v2: - The retention period of gold is 90 days. - Applies to all sites.
76
+ ```
77
+
78
+ *(Real output of `examples/compare_versions_demo.py`, which uses simulated runs and a scripted
79
+ stand-in for the LLM judge so it runs without an API key.)*
80
+
81
+ The key idea is the **noise floor**. A non-deterministic system never matches itself perfectly,
82
+ so comparing v2 to v1 naively overstates change. We measure how much v1 differs from *itself*
83
+ and v2 from *itself*, and report only the **extra** disagreement across versions (`drift`).
84
+ Two equally noisy systems show no drift; a real change does. Each input is also screened with a
85
+ permutation test (are its cross-version pairs less alike than its within-version pairs?), so a
86
+ noisy system does not get a long list of inputs flagged by chance. In the demo above, 36 inputs
87
+ were changed by construction and exactly those 36 are flagged. The report also says *what kind*
88
+ of change it is, since format drift and changed numbers deserve different decisions. Use 3 or
89
+ more runs per side, 5 if the system is noisy.
90
+
91
+ From the shell, with no code and no model (offline n-gram similarity), or with your own LLM:
92
+
93
+ ```bash
94
+ pip install repeatability-evals
95
+ repeatability-evals compare v1.json v2.json --out report.md
96
+ repeatability-evals compare v1.json v2.json --judge mypkg.llm:complete --rubric --out report.html
97
+ ```
98
+
99
+ Input is JSON, `{"input id": ["run 1", "run 2", ...]}`, or JSON Lines with one run per line,
100
+ `{"id": "...", "output": "...", "query": "..."}`, which is the shape most request logs already
101
+ have. The exit code is `0` (no meaningful change), `3` (inconclusive) or `4` (changed), so it can
102
+ gate a CI pipeline.
103
+
104
+ ## Usage pattern 2: Measure self-consistency
105
+
106
+ ```python
107
+ from repeatability_evals import PairwiseConsistency, JaccardSimilarity, NgramJaccard, RubricJudge
108
+
109
+ PairwiseConsistency(JaccardSimilarity()).evaluate(chunk_ids_per_run) # retrieved IDs
110
+ PairwiseConsistency(NgramJaccard(3)).evaluate(chunk_texts_per_run) # robust to re-chunking
111
+ PairwiseConsistency(RubricJudge(my_llm)).evaluate(answers, context={"query": q}) # answers
112
+ ```
113
+
114
+ Over a dataset, `evaluate_groups({input_id: runs, ...}, metric)` returns the mean, a bootstrap
115
+ confidence interval and the least consistent inputs.
116
+
117
+ ## Design
118
+
119
+ The library never knows whether you are comparing answers, chunk IDs or agent traces. It
120
+ compares *items*; you pick the similarity metric that makes sense for what you are comparing.
121
+
122
+ * **`Similarity`** scores two items in `[0, 1]`: sets (`JaccardSimilarity`, `DiceSimilarity`,
123
+ `OverlapCoefficient`), text (`ExactMatch`, `NgramJaccard`, `RougeL`, `EditSimilarity`),
124
+ rankings (`RankBiasedOverlap`), embeddings (`EmbeddingCosine`, bring your own embed
125
+ function) and LLM judges (`RubricJudge`, `LLMJudge`). Subclass it, or pass a plain function.
126
+ * **`PairSampler`** picks which of the N(N-1)/2 pairs to compare. The default `CycleSampler`
127
+ costs N comparisons and touches every run twice; `CrossSampler` does the same across versions.
128
+ * **Metrics**: `PairwiseConsistency` (mean similarity), `ClusterConsistency` (largest
129
+ "same answer" cluster, plus entropy), `TotalAgreement` (all N identical) and `compare_versions`.
130
+
131
+ Everything has an async twin (`aevaluate`, `acompare_versions`) with bounded concurrency. The
132
+ core has **no dependencies**; you bring the model, as any `str -> str` function.
133
+
134
+ ### The judge
135
+
136
+ `RubricJudge` scores five dimensions of agreement in one call: `core_facts`, `specifics`,
137
+ `supporting_detail`, `stance` (answer vs hedge vs refuse) and `format`. The headline is a
138
+ weighted mean (core facts count most) and the per-dimension means explain *what* varies. Pass
139
+ your own `Dimension`s to change them. `LLMJudge` is the single 1-5 semantic-equivalence score.
140
+ `symmetric=True` judges both orders to cancel position bias. **Validate any judge against human
141
+ ratings on your own data before relying on it.**
142
+
143
+ ### Running it at scale
144
+
145
+ LLM-judged evaluations over thousands of inputs should not fall over on one bad reply, and
146
+ should not pay for comparisons whose answer is obvious:
147
+
148
+ * **Failures**: judges re-ask when a reply has no parsable score (`retries=2`). Pass
149
+ `on_error="skip"` to metrics, `evaluate_groups` or `compare_versions` to drop a comparison that
150
+ still fails (timeout, API error) and keep going; skipped pairs and inputs are reported.
151
+ * **Cost**: identical outputs, which deterministic systems produce constantly, score 1.0 without a
152
+ model call (`skip_identical=True`), and repeated comparisons are cached. `EmbeddingCosine`
153
+ embeds each group in one batch.
154
+ * **Rate limits**: `concurrency` on `acompare_versions` (and `max_calls` on `aevaluate_groups`)
155
+ caps simultaneous judge calls across the whole run.
156
+ * **Prompt safety**: outputs are inserted into the judge prompt in one pass, inside tags, and the
157
+ judge is told to treat them as data, so placeholder-like text in a response cannot change the
158
+ prompt's structure. (No prompt fully rules out injection; review judge reasons on outliers.)
159
+
160
+ ### Sampling cost
161
+
162
+ All pairs for 10 runs is 45 judge calls; the default sampler uses 10. On synthetic data the
163
+ per-input error versus all pairs was about 0.03 with no bias (`benchmarks/sampling_error.py`);
164
+ check it on your data, or use `AllPairs()` for the exact value.
165
+
166
+ ### Writing your own similarity
167
+
168
+ ```python
169
+ from repeatability_evals import Similarity, PairwiseConsistency
170
+
171
+ class SameFirstSentence(Similarity):
172
+ name = "same_first_sentence"
173
+ def compute(self, a, b, *, context=None):
174
+ return float(a.split(".")[0] == b.split(".")[0])
175
+
176
+ PairwiseConsistency(SameFirstSentence()) # or PairwiseConsistency(lambda a, b: ...)
177
+ ```
178
+
@@ -0,0 +1,159 @@
1
+ # repeatability-evals
2
+
3
+ **Consistency evals for non-deterministic systems: LLM generation, agentic retrieval, agent workflows.**
4
+
5
+ Most evals ask *"is the output correct?"* and need gold labels to answer. This library asks
6
+ two questions that are extremely important in enterprise settings and need **no gold labels**:
7
+
8
+ 1. **Is my system consistent/repeatable with itself?** Same input, N runs: do the answers, the retrieved
9
+ documents, the tool-call traces agree?
10
+ 2. **Did anything user facing change when I changed something in the backend?** Model upgrade, prompt edit, new index,
11
+ framework bump: which inputs behave differently, *beyond the system's own run-to-run noise*,
12
+ and *how* (facts? numbers? format? refusals?).
13
+
14
+ ## Why it matters
15
+
16
+ | If you are... | You can... |
17
+ |---|---|
18
+ | **a platform or model provider** shipping an update | run it offline on a regression set and see which customer-style workflows would shift before you release |
19
+ | **an enterprise on a vendor's LLM or agent platform** | run it yourself, on your own data and cluster, to decide whether an upstream upgrade is safe for the workflows you built. Nothing leaves your environment, and `include_text=False` keeps raw text out of reports |
20
+ | **building an agent, RAG or harness product** | track whether retrieval and answers stay stable across runs, and catch silent drift from non-deterministic infrastructure |
21
+ | **without labelled data** | still get a signal: consistency needs only outputs, not answers |
22
+
23
+
24
+ ## Usage pattern 1: See what changed
25
+
26
+ ```python
27
+ from repeatability_evals import compare_versions, RubricJudge
28
+
29
+ # {input_id: [output of run 1, run 2, run 3, ...]} for each version. No labels.
30
+ report = compare_versions(v1_runs, v2_runs, RubricJudge(my_llm), names=("v1", "v2"))
31
+ print(report.verdict) # no_meaningful_change | changed | inconclusive
32
+ report.save("report.md") # also .html / .json
33
+ ```
34
+
35
+ ```
36
+ # v1 vs v2: consistency report
37
+ CHANGED: the candidate's outputs differ from the baseline's by more than run-to-run noise explains.
38
+ 120 inputs compared; 30% changed beyond the noise floor.
39
+
40
+ | v1 vs itself (noise floor) | 0.925 |
41
+ | v2 vs itself | 0.948 |
42
+ | v1 vs v2 | 0.850 |
43
+ | drift (expected - cross; 0 = no change) | +0.087 (95% CI +0.060 to +0.116) |
44
+
45
+ ## What changed
46
+ | format | +0.200 |
47
+ | specifics | +0.115 |
48
+ | supporting_detail | +0.100 |
49
+ | core_facts | +0.057 |
50
+ Changed inputs by main driver: 21 mainly in `format`, 15 mainly in `specifics`.
51
+
52
+ ### `q000`: drift +0.62 (cross 0.38 vs expected 1.00, p = 0.001)
53
+ - Main driver: `specifics`
54
+ - Judge: Retention period differs.
55
+ - v1: Retention for gold is 30 days.
56
+ - v2: - The retention period of gold is 90 days. - Applies to all sites.
57
+ ```
58
+
59
+ *(Real output of `examples/compare_versions_demo.py`, which uses simulated runs and a scripted
60
+ stand-in for the LLM judge so it runs without an API key.)*
61
+
62
+ The key idea is the **noise floor**. A non-deterministic system never matches itself perfectly,
63
+ so comparing v2 to v1 naively overstates change. We measure how much v1 differs from *itself*
64
+ and v2 from *itself*, and report only the **extra** disagreement across versions (`drift`).
65
+ Two equally noisy systems show no drift; a real change does. Each input is also screened with a
66
+ permutation test (are its cross-version pairs less alike than its within-version pairs?), so a
67
+ noisy system does not get a long list of inputs flagged by chance. In the demo above, 36 inputs
68
+ were changed by construction and exactly those 36 are flagged. The report also says *what kind*
69
+ of change it is, since format drift and changed numbers deserve different decisions. Use 3 or
70
+ more runs per side, 5 if the system is noisy.
71
+
72
+ From the shell, with no code and no model (offline n-gram similarity), or with your own LLM:
73
+
74
+ ```bash
75
+ pip install repeatability-evals
76
+ repeatability-evals compare v1.json v2.json --out report.md
77
+ repeatability-evals compare v1.json v2.json --judge mypkg.llm:complete --rubric --out report.html
78
+ ```
79
+
80
+ Input is JSON, `{"input id": ["run 1", "run 2", ...]}`, or JSON Lines with one run per line,
81
+ `{"id": "...", "output": "...", "query": "..."}`, which is the shape most request logs already
82
+ have. The exit code is `0` (no meaningful change), `3` (inconclusive) or `4` (changed), so it can
83
+ gate a CI pipeline.
84
+
85
+ ## Usage pattern 2: Measure self-consistency
86
+
87
+ ```python
88
+ from repeatability_evals import PairwiseConsistency, JaccardSimilarity, NgramJaccard, RubricJudge
89
+
90
+ PairwiseConsistency(JaccardSimilarity()).evaluate(chunk_ids_per_run) # retrieved IDs
91
+ PairwiseConsistency(NgramJaccard(3)).evaluate(chunk_texts_per_run) # robust to re-chunking
92
+ PairwiseConsistency(RubricJudge(my_llm)).evaluate(answers, context={"query": q}) # answers
93
+ ```
94
+
95
+ Over a dataset, `evaluate_groups({input_id: runs, ...}, metric)` returns the mean, a bootstrap
96
+ confidence interval and the least consistent inputs.
97
+
98
+ ## Design
99
+
100
+ The library never knows whether you are comparing answers, chunk IDs or agent traces. It
101
+ compares *items*; you pick the similarity metric that makes sense for what you are comparing.
102
+
103
+ * **`Similarity`** scores two items in `[0, 1]`: sets (`JaccardSimilarity`, `DiceSimilarity`,
104
+ `OverlapCoefficient`), text (`ExactMatch`, `NgramJaccard`, `RougeL`, `EditSimilarity`),
105
+ rankings (`RankBiasedOverlap`), embeddings (`EmbeddingCosine`, bring your own embed
106
+ function) and LLM judges (`RubricJudge`, `LLMJudge`). Subclass it, or pass a plain function.
107
+ * **`PairSampler`** picks which of the N(N-1)/2 pairs to compare. The default `CycleSampler`
108
+ costs N comparisons and touches every run twice; `CrossSampler` does the same across versions.
109
+ * **Metrics**: `PairwiseConsistency` (mean similarity), `ClusterConsistency` (largest
110
+ "same answer" cluster, plus entropy), `TotalAgreement` (all N identical) and `compare_versions`.
111
+
112
+ Everything has an async twin (`aevaluate`, `acompare_versions`) with bounded concurrency. The
113
+ core has **no dependencies**; you bring the model, as any `str -> str` function.
114
+
115
+ ### The judge
116
+
117
+ `RubricJudge` scores five dimensions of agreement in one call: `core_facts`, `specifics`,
118
+ `supporting_detail`, `stance` (answer vs hedge vs refuse) and `format`. The headline is a
119
+ weighted mean (core facts count most) and the per-dimension means explain *what* varies. Pass
120
+ your own `Dimension`s to change them. `LLMJudge` is the single 1-5 semantic-equivalence score.
121
+ `symmetric=True` judges both orders to cancel position bias. **Validate any judge against human
122
+ ratings on your own data before relying on it.**
123
+
124
+ ### Running it at scale
125
+
126
+ LLM-judged evaluations over thousands of inputs should not fall over on one bad reply, and
127
+ should not pay for comparisons whose answer is obvious:
128
+
129
+ * **Failures**: judges re-ask when a reply has no parsable score (`retries=2`). Pass
130
+ `on_error="skip"` to metrics, `evaluate_groups` or `compare_versions` to drop a comparison that
131
+ still fails (timeout, API error) and keep going; skipped pairs and inputs are reported.
132
+ * **Cost**: identical outputs, which deterministic systems produce constantly, score 1.0 without a
133
+ model call (`skip_identical=True`), and repeated comparisons are cached. `EmbeddingCosine`
134
+ embeds each group in one batch.
135
+ * **Rate limits**: `concurrency` on `acompare_versions` (and `max_calls` on `aevaluate_groups`)
136
+ caps simultaneous judge calls across the whole run.
137
+ * **Prompt safety**: outputs are inserted into the judge prompt in one pass, inside tags, and the
138
+ judge is told to treat them as data, so placeholder-like text in a response cannot change the
139
+ prompt's structure. (No prompt fully rules out injection; review judge reasons on outliers.)
140
+
141
+ ### Sampling cost
142
+
143
+ All pairs for 10 runs is 45 judge calls; the default sampler uses 10. On synthetic data the
144
+ per-input error versus all pairs was about 0.03 with no bias (`benchmarks/sampling_error.py`);
145
+ check it on your data, or use `AllPairs()` for the exact value.
146
+
147
+ ### Writing your own similarity
148
+
149
+ ```python
150
+ from repeatability_evals import Similarity, PairwiseConsistency
151
+
152
+ class SameFirstSentence(Similarity):
153
+ name = "same_first_sentence"
154
+ def compute(self, a, b, *, context=None):
155
+ return float(a.split(".")[0] == b.split(".")[0])
156
+
157
+ PairwiseConsistency(SameFirstSentence()) # or PairwiseConsistency(lambda a, b: ...)
158
+ ```
159
+
@@ -0,0 +1,57 @@
1
+ """Simulated demo: compare two versions of a Q&A system with no gold labels.
2
+
3
+ No API key needed. The "system" and the "judge" are scripted stand-ins so the demo is
4
+ deterministic; in real use, `judge` is a call to your LLM and the runs come from your system.
5
+ Run: python examples/compare_versions_demo.py
6
+ """
7
+ import json
8
+ import random
9
+ import re
10
+
11
+ from repeatability_evals import DEFAULT_DIMENSIONS, RubricJudge, compare_versions
12
+
13
+ rng = random.Random(7)
14
+ POLICIES = ["gold", "silver", "bronze", "archive", "dr", "edge"]
15
+
16
+
17
+ def answer(policy, days, bullets, noisy):
18
+ """One run of a pretend system. `noisy` runs sometimes reword (same meaning)."""
19
+ lead = rng.choice(["Retention for", "The retention period of"]) if noisy else "Retention for"
20
+ if noisy and rng.random() < 0.08: # occasional flaky run: a wrong number
21
+ days += 1
22
+ body = f"{lead} {policy} is {days} days."
23
+ return f"- {body}\n- Applies to all sites." if bullets else body
24
+
25
+
26
+ def version(days_for, bullets_for):
27
+ runs = {}
28
+ for q in range(120):
29
+ p = POLICIES[q % len(POLICIES)]
30
+ runs[f"q{q:03d}"] = [answer(p, days_for(q), bullets_for(q), True) for _ in range(4)]
31
+ return runs
32
+
33
+
34
+ v1 = version(lambda q: 30, lambda q: False)
35
+ # v2 changes the retention number on ~12% of questions and switches ~20% to bulleted format.
36
+ v2 = version(lambda q: 90 if q % 8 == 0 else 30, lambda q: q % 5 == 0)
37
+
38
+
39
+ def judge(prompt):
40
+ """Scripted stand-in for an LLM: scores each dimension by simple rules."""
41
+ a = prompt.split("<response_a>")[1].split("</response_a>")[0]
42
+ b = prompt.split("<response_b>")[1].split("</response_b>")[0]
43
+ num = lambda t: re.findall(r"\d+", t) # noqa: E731
44
+ bullets = lambda t: t.lstrip().startswith("-") # noqa: E731
45
+ scores = {d.name: 5 for d in DEFAULT_DIMENSIONS}
46
+ if num(a)[:1] != num(b)[:1]:
47
+ scores["specifics"], scores["core_facts"] = 1, 3
48
+ if bullets(a) != bullets(b):
49
+ scores["format"], scores["supporting_detail"] = 1, 3
50
+ why = "Retention period differs." if scores["specifics"] == 1 else (
51
+ "Same facts; formatting differs." if scores["format"] == 1 else "Equivalent.")
52
+ return json.dumps({"reason": why, "scores": scores})
53
+
54
+
55
+ report = compare_versions(v1, v2, RubricJudge(judge), names=("v1", "v2"))
56
+ print(report.to_markdown(top=2))
57
+ report.save("examples/demo_report.md")
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "repeatability-evals"
7
- version = "0.1.0"
7
+ version = "0.3.0"
8
8
  description = "Consistency evals for non-deterministic systems: LLM generation, agentic retrieval, agent workflows."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -35,6 +35,9 @@ src = ["src", "tests"]
35
35
  select = ["E", "F", "I", "B", "UP"]
36
36
  ignore = ["UP006", "UP007", "UP035", "UP045"] # keep typing.Optional/List for py3.9 readability
37
37
 
38
+ [project.scripts]
39
+ repeatability-evals = "repeatability_evals.cli:main"
40
+
38
41
  [project.urls]
39
42
  Homepage = "https://github.com/krishna-gogineni-765/repeatability-evals"
40
43
  Issues = "https://github.com/krishna-gogineni-765/repeatability-evals/issues"
@@ -1,8 +1,16 @@
1
1
  """repeatability-evals: consistency evals for non-deterministic LLM systems."""
2
+ from .compare import InputComparison, VersionComparison, acompare_versions, compare_versions
2
3
  from .dataset import DatasetResult, aevaluate_groups, evaluate_groups
3
- from .metrics import ClusterConsistency, ConsistencyMetric, PairwiseConsistency, TotalAgreement
4
+ from .metrics import (
5
+ AllComparisonsFailed,
6
+ ClusterConsistency,
7
+ ConsistencyMetric,
8
+ PairwiseConsistency,
9
+ TotalAgreement,
10
+ call_limit,
11
+ )
4
12
  from .prompts import DEFAULT_DIMENSIONS, SEMANTIC_EQUIVALENCE_PROMPT, Dimension, build_rubric_prompt
5
- from .sampling import AllPairs, AnchorPairs, CycleSampler, PairSampler, RandomPairs, cycle_pairs
13
+ from .sampling import AllPairs, AnchorPairs, CrossSampler, CycleSampler, PairSampler, RandomPairs, cycle_pairs
6
14
  from .similarity import (
7
15
  DiceSimilarity,
8
16
  EditSimilarity,
@@ -19,13 +27,22 @@ from .similarity import (
19
27
  RubricJudge,
20
28
  Similarity,
21
29
  as_similarity,
30
+ render_prompt,
22
31
  )
23
32
  from .stats import bootstrap_ci
24
33
  from .types import ConsistencyResult, PairScore, SimilarityResult
25
34
 
26
- __version__ = "0.1.0"
35
+ try:
36
+ from importlib.metadata import PackageNotFoundError
37
+ from importlib.metadata import version as _version
38
+
39
+ __version__ = _version("repeatability-evals")
40
+ except PackageNotFoundError: # running from a source checkout that is not installed
41
+ __version__ = "0+unknown"
27
42
 
28
43
  __all__ = [
44
+ "call_limit", "AllComparisonsFailed", "render_prompt",
45
+ "compare_versions", "acompare_versions", "VersionComparison", "InputComparison", "CrossSampler",
29
46
  "ConsistencyMetric", "PairwiseConsistency", "ClusterConsistency", "TotalAgreement",
30
47
  "evaluate_groups", "aevaluate_groups", "DatasetResult", "bootstrap_ci",
31
48
  "Similarity", "FunctionSimilarity", "as_similarity",
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())