repeatability-evals 0.1.0__tar.gz → 0.2.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 (36) hide show
  1. {repeatability_evals-0.1.0 → repeatability_evals-0.2.0}/.github/workflows/ci.yml +0 -0
  2. {repeatability_evals-0.1.0 → repeatability_evals-0.2.0}/.github/workflows/publish.yml +0 -0
  3. {repeatability_evals-0.1.0 → repeatability_evals-0.2.0}/.gitignore +1 -0
  4. {repeatability_evals-0.1.0 → repeatability_evals-0.2.0}/LICENSE +0 -0
  5. repeatability_evals-0.2.0/PKG-INFO +155 -0
  6. repeatability_evals-0.2.0/README.md +136 -0
  7. {repeatability_evals-0.1.0 → repeatability_evals-0.2.0}/benchmarks/sampling_error.py +0 -0
  8. repeatability_evals-0.2.0/examples/compare_versions_demo.py +57 -0
  9. {repeatability_evals-0.1.0 → repeatability_evals-0.2.0}/pyproject.toml +4 -1
  10. {repeatability_evals-0.1.0 → repeatability_evals-0.2.0}/src/repeatability_evals/__init__.py +3 -1
  11. repeatability_evals-0.2.0/src/repeatability_evals/__main__.py +3 -0
  12. repeatability_evals-0.2.0/src/repeatability_evals/cli.py +98 -0
  13. repeatability_evals-0.2.0/src/repeatability_evals/compare.py +371 -0
  14. {repeatability_evals-0.1.0 → repeatability_evals-0.2.0}/src/repeatability_evals/dataset.py +0 -0
  15. {repeatability_evals-0.1.0 → repeatability_evals-0.2.0}/src/repeatability_evals/metrics.py +0 -0
  16. {repeatability_evals-0.1.0 → repeatability_evals-0.2.0}/src/repeatability_evals/prompts.py +0 -0
  17. {repeatability_evals-0.1.0 → repeatability_evals-0.2.0}/src/repeatability_evals/py.typed +0 -0
  18. {repeatability_evals-0.1.0 → repeatability_evals-0.2.0}/src/repeatability_evals/sampling.py +38 -0
  19. {repeatability_evals-0.1.0 → repeatability_evals-0.2.0}/src/repeatability_evals/similarity/__init__.py +0 -0
  20. {repeatability_evals-0.1.0 → repeatability_evals-0.2.0}/src/repeatability_evals/similarity/base.py +0 -0
  21. {repeatability_evals-0.1.0 → repeatability_evals-0.2.0}/src/repeatability_evals/similarity/embedding.py +0 -0
  22. {repeatability_evals-0.1.0 → repeatability_evals-0.2.0}/src/repeatability_evals/similarity/judge.py +0 -0
  23. {repeatability_evals-0.1.0 → repeatability_evals-0.2.0}/src/repeatability_evals/similarity/ranking.py +0 -0
  24. {repeatability_evals-0.1.0 → repeatability_evals-0.2.0}/src/repeatability_evals/similarity/sets.py +0 -0
  25. {repeatability_evals-0.1.0 → repeatability_evals-0.2.0}/src/repeatability_evals/similarity/text.py +0 -0
  26. {repeatability_evals-0.1.0 → repeatability_evals-0.2.0}/src/repeatability_evals/stats.py +0 -0
  27. {repeatability_evals-0.1.0 → repeatability_evals-0.2.0}/src/repeatability_evals/types.py +0 -0
  28. repeatability_evals-0.2.0/tests/test_cli.py +32 -0
  29. repeatability_evals-0.2.0/tests/test_compare.py +148 -0
  30. {repeatability_evals-0.1.0 → repeatability_evals-0.2.0}/tests/test_dataset.py +0 -0
  31. {repeatability_evals-0.1.0 → repeatability_evals-0.2.0}/tests/test_judge.py +0 -0
  32. {repeatability_evals-0.1.0 → repeatability_evals-0.2.0}/tests/test_metrics.py +0 -0
  33. {repeatability_evals-0.1.0 → repeatability_evals-0.2.0}/tests/test_sampling.py +0 -0
  34. {repeatability_evals-0.1.0 → repeatability_evals-0.2.0}/tests/test_similarity.py +0 -0
  35. repeatability_evals-0.1.0/PKG-INFO +0 -147
  36. repeatability_evals-0.1.0/README.md +0 -128
@@ -4,3 +4,4 @@ __pycache__/
4
4
  .pytest_cache/
5
5
  .ruff_cache/
6
6
  dist/
7
+ examples/demo_report.md
@@ -0,0 +1,155 @@
1
+ Metadata-Version: 2.5
2
+ Name: repeatability-evals
3
+ Version: 0.2.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 settinga 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; 34% changed beyond the noise floor (per-input drift > 0.1).
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`, 20 mainly in `specifics`.
70
+
71
+ ### `q000`: drift +0.62 (cross 0.38 vs expected 1.00)
72
+ - Main driver: `specifics`
73
+ - Judge: Retention period differs.
74
+ - v1: Retention for gold is 30 days.
75
+ - v2: - Retention for 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. The report also says *what kind*
85
+ of change it is, since format drift and changed numbers deserve different decisions.
86
+
87
+ From the shell, with no code and no model (offline n-gram similarity), or with your own LLM:
88
+
89
+ ```bash
90
+ pip install repeatability-evals
91
+ repeatability-evals compare v1.json v2.json --out report.md
92
+ repeatability-evals compare v1.json v2.json --judge mypkg.llm:complete --rubric --out report.html
93
+ ```
94
+
95
+ Input is JSON: `{"input id": ["run 1", "run 2", ...]}`. The exit code is `0` (no meaningful
96
+ change), `1` (inconclusive) or `2` (changed), so it can gate a CI pipeline.
97
+
98
+ ## Usage pattern 2: Measure self-consistency
99
+
100
+ ```python
101
+ from repeatability_evals import PairwiseConsistency, JaccardSimilarity, NgramJaccard, RubricJudge
102
+
103
+ PairwiseConsistency(JaccardSimilarity()).evaluate(chunk_ids_per_run) # retrieved IDs
104
+ PairwiseConsistency(NgramJaccard(3)).evaluate(chunk_texts_per_run) # robust to re-chunking
105
+ PairwiseConsistency(RubricJudge(my_llm)).evaluate(answers, context={"query": q}) # answers
106
+ ```
107
+
108
+ Over a dataset, `evaluate_groups({input_id: runs, ...}, metric)` returns the mean, a bootstrap
109
+ confidence interval and the least consistent inputs.
110
+
111
+ ## Design
112
+
113
+ The library never knows whether you are comparing answers, chunk IDs or agent traces. It
114
+ compares *items*; you pick the similarity metric that makes sense for what you are comparing.
115
+
116
+ * **`Similarity`** scores two items in `[0, 1]`: sets (`JaccardSimilarity`, `DiceSimilarity`,
117
+ `OverlapCoefficient`), text (`ExactMatch`, `NgramJaccard`, `RougeL`, `EditSimilarity`),
118
+ rankings (`RankBiasedOverlap`), embeddings (`EmbeddingCosine`, bring your own embed
119
+ function) and LLM judges (`RubricJudge`, `LLMJudge`). Subclass it, or pass a plain function.
120
+ * **`PairSampler`** picks which of the N(N-1)/2 pairs to compare. The default `CycleSampler`
121
+ costs N comparisons and touches every run twice; `CrossSampler` does the same across versions.
122
+ * **Metrics**: `PairwiseConsistency` (mean similarity), `ClusterConsistency` (largest
123
+ "same answer" cluster, plus entropy), `TotalAgreement` (all N identical) and `compare_versions`.
124
+
125
+ Everything has an async twin (`aevaluate`, `acompare_versions`) with bounded concurrency. The
126
+ core has **no dependencies**; you bring the model, as any `str -> str` function.
127
+
128
+ ### The judge
129
+
130
+ `RubricJudge` scores five dimensions of agreement in one call: `core_facts`, `specifics`,
131
+ `supporting_detail`, `stance` (answer vs hedge vs refuse) and `format`. The headline is a
132
+ weighted mean (core facts count most) and the per-dimension means explain *what* varies. Pass
133
+ your own `Dimension`s to change them. `LLMJudge` is the single 1-5 semantic-equivalence score.
134
+ `symmetric=True` judges both orders to cancel position bias. **Validate any judge against human
135
+ ratings on your own data before relying on it.**
136
+
137
+ ### Sampling cost
138
+
139
+ All pairs for 10 runs is 45 judge calls; the default sampler uses 10. On synthetic data the
140
+ per-input error versus all pairs was about 0.03 with no bias (`benchmarks/sampling_error.py`);
141
+ check it on your data, or use `AllPairs()` for the exact value.
142
+
143
+ ### Writing your own similarity
144
+
145
+ ```python
146
+ from repeatability_evals import Similarity, PairwiseConsistency
147
+
148
+ class SameFirstSentence(Similarity):
149
+ name = "same_first_sentence"
150
+ def compute(self, a, b, *, context=None):
151
+ return float(a.split(".")[0] == b.split(".")[0])
152
+
153
+ PairwiseConsistency(SameFirstSentence()) # or PairwiseConsistency(lambda a, b: ...)
154
+ ```
155
+
@@ -0,0 +1,136 @@
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 settinga 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; 34% changed beyond the noise floor (per-input drift > 0.1).
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`, 20 mainly in `specifics`.
51
+
52
+ ### `q000`: drift +0.62 (cross 0.38 vs expected 1.00)
53
+ - Main driver: `specifics`
54
+ - Judge: Retention period differs.
55
+ - v1: Retention for gold is 30 days.
56
+ - v2: - Retention for 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. The report also says *what kind*
66
+ of change it is, since format drift and changed numbers deserve different decisions.
67
+
68
+ From the shell, with no code and no model (offline n-gram similarity), or with your own LLM:
69
+
70
+ ```bash
71
+ pip install repeatability-evals
72
+ repeatability-evals compare v1.json v2.json --out report.md
73
+ repeatability-evals compare v1.json v2.json --judge mypkg.llm:complete --rubric --out report.html
74
+ ```
75
+
76
+ Input is JSON: `{"input id": ["run 1", "run 2", ...]}`. The exit code is `0` (no meaningful
77
+ change), `1` (inconclusive) or `2` (changed), so it can gate a CI pipeline.
78
+
79
+ ## Usage pattern 2: Measure self-consistency
80
+
81
+ ```python
82
+ from repeatability_evals import PairwiseConsistency, JaccardSimilarity, NgramJaccard, RubricJudge
83
+
84
+ PairwiseConsistency(JaccardSimilarity()).evaluate(chunk_ids_per_run) # retrieved IDs
85
+ PairwiseConsistency(NgramJaccard(3)).evaluate(chunk_texts_per_run) # robust to re-chunking
86
+ PairwiseConsistency(RubricJudge(my_llm)).evaluate(answers, context={"query": q}) # answers
87
+ ```
88
+
89
+ Over a dataset, `evaluate_groups({input_id: runs, ...}, metric)` returns the mean, a bootstrap
90
+ confidence interval and the least consistent inputs.
91
+
92
+ ## Design
93
+
94
+ The library never knows whether you are comparing answers, chunk IDs or agent traces. It
95
+ compares *items*; you pick the similarity metric that makes sense for what you are comparing.
96
+
97
+ * **`Similarity`** scores two items in `[0, 1]`: sets (`JaccardSimilarity`, `DiceSimilarity`,
98
+ `OverlapCoefficient`), text (`ExactMatch`, `NgramJaccard`, `RougeL`, `EditSimilarity`),
99
+ rankings (`RankBiasedOverlap`), embeddings (`EmbeddingCosine`, bring your own embed
100
+ function) and LLM judges (`RubricJudge`, `LLMJudge`). Subclass it, or pass a plain function.
101
+ * **`PairSampler`** picks which of the N(N-1)/2 pairs to compare. The default `CycleSampler`
102
+ costs N comparisons and touches every run twice; `CrossSampler` does the same across versions.
103
+ * **Metrics**: `PairwiseConsistency` (mean similarity), `ClusterConsistency` (largest
104
+ "same answer" cluster, plus entropy), `TotalAgreement` (all N identical) and `compare_versions`.
105
+
106
+ Everything has an async twin (`aevaluate`, `acompare_versions`) with bounded concurrency. The
107
+ core has **no dependencies**; you bring the model, as any `str -> str` function.
108
+
109
+ ### The judge
110
+
111
+ `RubricJudge` scores five dimensions of agreement in one call: `core_facts`, `specifics`,
112
+ `supporting_detail`, `stance` (answer vs hedge vs refuse) and `format`. The headline is a
113
+ weighted mean (core facts count most) and the per-dimension means explain *what* varies. Pass
114
+ your own `Dimension`s to change them. `LLMJudge` is the single 1-5 semantic-equivalence score.
115
+ `symmetric=True` judges both orders to cancel position bias. **Validate any judge against human
116
+ ratings on your own data before relying on it.**
117
+
118
+ ### Sampling cost
119
+
120
+ All pairs for 10 runs is 45 judge calls; the default sampler uses 10. On synthetic data the
121
+ per-input error versus all pairs was about 0.03 with no bias (`benchmarks/sampling_error.py`);
122
+ check it on your data, or use `AllPairs()` for the exact value.
123
+
124
+ ### Writing your own similarity
125
+
126
+ ```python
127
+ from repeatability_evals import Similarity, PairwiseConsistency
128
+
129
+ class SameFirstSentence(Similarity):
130
+ name = "same_first_sentence"
131
+ def compute(self, a, b, *, context=None):
132
+ return float(a.split(".")[0] == b.split(".")[0])
133
+
134
+ PairwiseConsistency(SameFirstSentence()) # or PairwiseConsistency(lambda a, b: ...)
135
+ ```
136
+
@@ -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 B:")[0]
42
+ b = prompt.split("Response B:")[1].split("Score each dimension")[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.2.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,9 @@
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
4
  from .metrics import ClusterConsistency, ConsistencyMetric, PairwiseConsistency, TotalAgreement
4
5
  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 .sampling import AllPairs, AnchorPairs, CrossSampler, CycleSampler, PairSampler, RandomPairs, cycle_pairs
6
7
  from .similarity import (
7
8
  DiceSimilarity,
8
9
  EditSimilarity,
@@ -26,6 +27,7 @@ from .types import ConsistencyResult, PairScore, SimilarityResult
26
27
  __version__ = "0.1.0"
27
28
 
28
29
  __all__ = [
30
+ "compare_versions", "acompare_versions", "VersionComparison", "InputComparison", "CrossSampler",
29
31
  "ConsistencyMetric", "PairwiseConsistency", "ClusterConsistency", "TotalAgreement",
30
32
  "evaluate_groups", "aevaluate_groups", "DatasetResult", "bootstrap_ci",
31
33
  "Similarity", "FunctionSimilarity", "as_similarity",
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,98 @@
1
+ """Command line: compare two versions of a system from saved outputs, no gold labels.
2
+
3
+ repeatability-evals compare baseline.json candidate.json --out report.md
4
+
5
+ Input files are JSON: ``{"<input id>": ["run 1 output", "run 2 output", ...]}`` or
6
+ ``{"<input id>": {"query": "...", "outputs": [...]}}``. By default outputs are compared with
7
+ n-gram overlap (no model needed, runs fully offline). For semantic comparison pass
8
+ ``--judge package.module:function`` where the function takes a prompt string and returns the
9
+ model's reply (it may be ``async``); add ``--rubric`` for the multi-dimension judge.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import asyncio
15
+ import importlib
16
+ import inspect
17
+ import json
18
+ import sys
19
+ from typing import Any, Callable, Dict, List, Optional, Tuple
20
+
21
+ from .compare import acompare_versions, compare_versions
22
+ from .similarity import EditSimilarity, ExactMatch, LLMJudge, NgramJaccard, RougeL, RubricJudge
23
+
24
+
25
+ def load_runs(path: str) -> Tuple[Dict[str, List[Any]], Dict[str, Dict[str, Any]]]:
26
+ with open(path, encoding="utf-8") as f:
27
+ data = json.load(f)
28
+ if not isinstance(data, dict):
29
+ raise SystemExit(f"{path}: expected a JSON object mapping input id to runs")
30
+ runs: Dict[str, List[Any]] = {}
31
+ contexts: Dict[str, Dict[str, Any]] = {}
32
+ for key, val in data.items():
33
+ if isinstance(val, dict):
34
+ runs[key] = list(val.get("outputs", []))
35
+ if val.get("query") is not None:
36
+ contexts[key] = {"query": val["query"]}
37
+ else:
38
+ runs[key] = list(val)
39
+ return runs, contexts
40
+
41
+
42
+ def _import_callable(spec: str) -> Callable[..., Any]:
43
+ mod, _, attr = spec.partition(":")
44
+ if not attr:
45
+ raise SystemExit("--judge must look like package.module:function")
46
+ sys.path.insert(0, ".")
47
+ return getattr(importlib.import_module(mod), attr)
48
+
49
+
50
+ def main(argv: Optional[List[str]] = None) -> int:
51
+ ap = argparse.ArgumentParser(prog="repeatability-evals", description=__doc__.split("\n")[0])
52
+ sub = ap.add_subparsers(dest="cmd", required=True)
53
+ c = sub.add_parser("compare", help="compare a candidate version against a baseline")
54
+ c.add_argument("baseline")
55
+ c.add_argument("candidate")
56
+ c.add_argument("--similarity", choices=["ngram", "rouge", "edit", "exact"], default="ngram",
57
+ help="offline similarity used when no --judge is given (default: ngram)")
58
+ c.add_argument("--judge", metavar="MODULE:FUNC", help="LLM completion function (prompt -> reply)")
59
+ c.add_argument("--rubric", action="store_true", help="use the 5-dimension RubricJudge")
60
+ c.add_argument("--symmetric", action="store_true", help="judge both orders (2x cost)")
61
+ c.add_argument("--tolerance", type=float, default=0.1, help="per-input drift counted as changed")
62
+ c.add_argument("--acceptable-drift", type=float, default=0.05, help="mean drift you would ship")
63
+ c.add_argument("--concurrency", type=int, default=8)
64
+ c.add_argument("--no-text", action="store_true", help="omit raw outputs from the report")
65
+ c.add_argument("--names", nargs=2, metavar=("BASE", "CAND"), default=["baseline", "candidate"])
66
+ c.add_argument("--out", help="write report here (.md, .html or .json); default: print markdown")
67
+ args = ap.parse_args(argv)
68
+
69
+ base, ctx_a = load_runs(args.baseline)
70
+ cand, ctx_b = load_runs(args.candidate)
71
+ contexts = {**ctx_b, **ctx_a}
72
+ judge_fn = _import_callable(args.judge) if args.judge else None
73
+ if judge_fn is not None:
74
+ cls = RubricJudge if args.rubric else LLMJudge
75
+ kw: Dict[str, Any] = {"symmetric": args.symmetric}
76
+ sim = (cls(allm=judge_fn, **kw) if inspect.iscoroutinefunction(judge_fn)
77
+ else cls(judge_fn, **kw))
78
+ else:
79
+ sim = {"ngram": NgramJaccard(2), "rouge": RougeL(), "edit": EditSimilarity(),
80
+ "exact": ExactMatch()}[args.similarity]
81
+ kwargs: Dict[str, Any] = dict(contexts=contexts, tolerance=args.tolerance,
82
+ acceptable_drift=args.acceptable_drift, names=tuple(args.names),
83
+ include_examples=not args.no_text)
84
+ if judge_fn is not None:
85
+ report = asyncio.run(acompare_versions(base, cand, sim, concurrency=args.concurrency, **kwargs))
86
+ else:
87
+ report = compare_versions(base, cand, sim, **kwargs)
88
+ if args.out:
89
+ report.save(args.out, include_text=not args.no_text)
90
+ print(f"{report.verdict}: drift {report.drift:+.3f}, "
91
+ f"{report.share_changed:.0%} of {report.n_inputs} inputs changed -> {args.out}")
92
+ else:
93
+ print(report.to_markdown(include_text=not args.no_text))
94
+ return {"no_meaningful_change": 0, "inconclusive": 1, "changed": 2}[report.verdict]
95
+
96
+
97
+ if __name__ == "__main__":
98
+ raise SystemExit(main())