repeatability-evals 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.
@@ -0,0 +1,56 @@
1
+ """Embedding-based semantic similarity. Bring your own embedding function: no dependencies."""
2
+ from __future__ import annotations
3
+
4
+ import math
5
+ from typing import Any, Callable, Dict, List, Sequence
6
+
7
+ from .base import Context, Similarity
8
+
9
+ EmbedFn = Callable[[List[str]], Sequence[Sequence[float]]]
10
+
11
+
12
+ class EmbeddingCosine(Similarity):
13
+ """Cosine similarity of text embeddings.
14
+
15
+ ``embed`` takes a list of strings and returns one vector each, which is the shape of
16
+ ``SentenceTransformer.encode`` and of most embedding API clients, e.g.::
17
+
18
+ from sentence_transformers import SentenceTransformer
19
+ model = SentenceTransformer("all-MiniLM-L6-v2")
20
+ sim = EmbeddingCosine(lambda texts: model.encode(texts).tolist())
21
+
22
+ ``rescale="clip"`` maps negative cosines to 0 (the usual convention for text
23
+ embeddings); ``"affine"`` maps [-1, 1] onto [0, 1]. Embeddings are cached per text
24
+ because the same item is compared against several others.
25
+ """
26
+
27
+ name = "embedding_cosine"
28
+
29
+ def __init__(self, embed: EmbedFn, *, rescale: str = "clip", max_cache: int = 20000) -> None:
30
+ if rescale not in ("clip", "affine"):
31
+ raise ValueError("rescale must be 'clip' or 'affine'")
32
+ self._embed, self._rescale, self._max_cache = embed, rescale, max_cache
33
+ self._cache: Dict[str, Sequence[float]] = {}
34
+
35
+ def _vectors(self, texts: List[str]) -> List[Sequence[float]]:
36
+ missing = [t for t in dict.fromkeys(texts) if t not in self._cache]
37
+ if missing:
38
+ if len(self._cache) + len(missing) > self._max_cache:
39
+ self._cache.clear()
40
+ vecs = self._embed(missing)
41
+ if len(vecs) != len(missing):
42
+ raise ValueError("embed() must return one vector per input text")
43
+ self._cache.update(zip(missing, vecs))
44
+ return [self._cache[t] for t in texts]
45
+
46
+ def compute(self, a: Any, b: Any, *, context: Context = None) -> float:
47
+ ta, tb = str(a), str(b)
48
+ if ta == tb:
49
+ return 1.0
50
+ va, vb = self._vectors([ta, tb])
51
+ na = math.sqrt(sum(x * x for x in va))
52
+ nb = math.sqrt(sum(x * x for x in vb))
53
+ if na == 0 or nb == 0:
54
+ return 0.0
55
+ cos = sum(x * y for x, y in zip(va, vb)) / (na * nb)
56
+ return max(0.0, cos) if self._rescale == "clip" else (cos + 1) / 2
@@ -0,0 +1,185 @@
1
+ """LLM-as-judge semantic equivalence. Model-agnostic: you supply the completion function."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ from typing import Any, Awaitable, Callable, Dict, Optional, Sequence, Tuple
7
+
8
+ from ..prompts import DEFAULT_DIMENSIONS, SEMANTIC_EQUIVALENCE_PROMPT, Dimension, build_rubric_prompt
9
+ from ..types import SimilarityResult
10
+ from .base import Context, Similarity
11
+
12
+ CompleteFn = Callable[[str], str]
13
+ ACompleteFn = Callable[[str], Awaitable[str]]
14
+
15
+
16
+ class JudgeParseError(ValueError):
17
+ """The judge's reply contained no usable score."""
18
+
19
+
20
+ def parse_judge_output(text: str, scale: Tuple[float, float]) -> Tuple[float, Optional[str]]:
21
+ """Extract (score, reason) from a judge reply: JSON first, then 'score: N', then last number."""
22
+ lo, hi = scale
23
+ match = re.search(r"\{.*\}", text, re.S)
24
+ if match:
25
+ try:
26
+ obj = json.loads(match.group(0))
27
+ if isinstance(obj, dict) and "score" in obj:
28
+ return float(obj["score"]), obj.get("reason")
29
+ except (ValueError, TypeError):
30
+ pass
31
+ m = re.search(r"(?i)\b(?:score|rating)\b\D{0,12}(-?\d+(?:\.\d+)?)", text)
32
+ if m:
33
+ return float(m.group(1)), None
34
+ nums = [float(x) for x in re.findall(r"(?<![\d.])\d+(?:\.\d+)?(?![\d.])", text)]
35
+ nums = [x for x in nums if lo <= x <= hi]
36
+ if nums:
37
+ return nums[-1], None
38
+ raise JudgeParseError(f"no score found in judge reply: {text[:200]!r}")
39
+
40
+
41
+ class LLMJudge(Similarity):
42
+ """Semantic equivalence of two items, scored by an LLM on a 1-5 rubric.
43
+
44
+ ``llm`` is any ``str -> str`` completion function (and/or ``allm``, its async twin), so
45
+ this works with any provider or local model. The score is normalised to [0, 1]; the
46
+ raw rating and the judge's reason are kept on the ``SimilarityResult``. Put the query
47
+ both items answer in ``context={"query": ...}`` and the prompt will include it.
48
+
49
+ ``symmetric=True`` judges both (a, b) and (b, a) and averages them, removing position
50
+ bias at twice the cost.
51
+ """
52
+
53
+ name = "llm_judge"
54
+
55
+ def __init__(
56
+ self,
57
+ llm: Optional[CompleteFn] = None,
58
+ allm: Optional[ACompleteFn] = None,
59
+ *,
60
+ prompt: str = SEMANTIC_EQUIVALENCE_PROMPT,
61
+ scale: Tuple[float, float] = (1.0, 5.0),
62
+ symmetric: bool = False,
63
+ ) -> None:
64
+ if llm is None and allm is None:
65
+ raise ValueError("provide llm and/or allm")
66
+ if scale[1] <= scale[0]:
67
+ raise ValueError("scale must be (low, high) with high > low")
68
+ self._llm, self._allm = llm, allm
69
+ self.prompt, self.scale, self.symmetric = prompt, scale, symmetric
70
+
71
+ def _render(self, a: Any, b: Any, context: Context) -> str:
72
+ query = str((context or {}).get("query", "") or "(not provided)")
73
+ return (
74
+ self.prompt.replace("{query}", query)
75
+ .replace("{item_a}", str(a))
76
+ .replace("{item_b}", str(b))
77
+ )
78
+
79
+ def _parse(self, reply: str) -> Tuple[float, Optional[str]]:
80
+ raw, reason = parse_judge_output(reply, self.scale)
81
+ return min(max(raw, self.scale[0]), self.scale[1]), reason
82
+
83
+ def _finish(self, parsed: list) -> SimilarityResult:
84
+ lo, hi = self.scale
85
+ raw = sum(p[0] for p in parsed) / len(parsed)
86
+ reason = next((p[1] for p in parsed if p[1]), None)
87
+ return SimilarityResult(score=(raw - lo) / (hi - lo), raw=raw, reason=reason)
88
+
89
+ def compute(self, a: Any, b: Any, *, context: Context = None) -> SimilarityResult:
90
+ if self._llm is None:
91
+ raise RuntimeError("this judge only has an async llm; use aevaluate()/ascore()")
92
+ orders = [(a, b), (b, a)] if self.symmetric else [(a, b)]
93
+ return self._finish([self._parse(self._llm(self._render(x, y, context))) for x, y in orders])
94
+
95
+ async def acompute(self, a: Any, b: Any, *, context: Context = None) -> SimilarityResult:
96
+ if self._allm is None:
97
+ return await super().acompute(a, b, context=context) # type: ignore[return-value]
98
+ orders = [(a, b), (b, a)] if self.symmetric else [(a, b)]
99
+ parsed = [self._parse(await self._allm(self._render(x, y, context))) for x, y in orders]
100
+ return self._finish(parsed)
101
+
102
+
103
+ def parse_rubric_output(
104
+ text: str, names: Sequence[str], scale: Tuple[float, float]
105
+ ) -> Tuple[Dict[str, float], Optional[str]]:
106
+ """Extract ({dimension: score}, reason) from a rubric judge reply (JSON with a "scores" object)."""
107
+ match = re.search(r"\{.*\}", text, re.S)
108
+ if not match:
109
+ raise JudgeParseError(f"no JSON found in judge reply: {text[:200]!r}")
110
+ try:
111
+ obj = json.loads(match.group(0))
112
+ scores = obj["scores"]
113
+ out = {n: min(max(float(scores[n]), scale[0]), scale[1]) for n in names}
114
+ except (ValueError, TypeError, KeyError) as e:
115
+ raise JudgeParseError(f"missing or invalid dimension scores ({e!r}): {text[:200]!r}") from e
116
+ return out, obj.get("reason")
117
+
118
+
119
+ class RubricJudge(Similarity):
120
+ """Multi-dimension LLM judge: one call scores several aspects of agreement (1-5 each).
121
+
122
+ Default dimensions: ``core_facts``, ``specifics``, ``supporting_detail``, ``stance``,
123
+ ``format`` (see ``repeatability_evals.prompts.DEFAULT_DIMENSIONS``). The similarity score is
124
+ their weighted mean, normalised to [0, 1]; the per-dimension scores are kept on the result
125
+ (``metadata["dimensions"]``) and averaged per dimension in ``PairwiseConsistency`` details, so
126
+ you can see *what* varies between runs (facts? format?) and not just *whether* they differ.
127
+
128
+ Pass your own ``dimensions`` (any number, any weights) to adapt it, e.g. for agent traces.
129
+ Weights are normalised to sum to 1. ``symmetric=True`` judges both orders and averages.
130
+ """
131
+
132
+ name = "rubric_judge"
133
+
134
+ def __init__(
135
+ self,
136
+ llm: Optional[CompleteFn] = None,
137
+ allm: Optional[ACompleteFn] = None,
138
+ *,
139
+ dimensions: Sequence[Dimension] = DEFAULT_DIMENSIONS,
140
+ prompt: Optional[str] = None,
141
+ scale: Tuple[float, float] = (1.0, 5.0),
142
+ symmetric: bool = False,
143
+ ) -> None:
144
+ if llm is None and allm is None:
145
+ raise ValueError("provide llm and/or allm")
146
+ if scale[1] <= scale[0]:
147
+ raise ValueError("scale must be (low, high) with high > low")
148
+ if not dimensions or any(d.weight <= 0 for d in dimensions):
149
+ raise ValueError("need at least one dimension, each with weight > 0")
150
+ if len({d.name for d in dimensions}) != len(dimensions):
151
+ raise ValueError("dimension names must be unique")
152
+ self._llm, self._allm = llm, allm
153
+ self.dimensions = tuple(dimensions)
154
+ self.prompt = prompt or build_rubric_prompt(self.dimensions)
155
+ self.scale, self.symmetric = scale, symmetric
156
+ self._names = [d.name for d in self.dimensions]
157
+ total = sum(d.weight for d in self.dimensions)
158
+ self._weights = {d.name: d.weight / total for d in self.dimensions}
159
+
160
+ _render = LLMJudge._render
161
+
162
+ def _parse(self, reply: str) -> Tuple[Dict[str, float], Optional[str]]:
163
+ return parse_rubric_output(reply, self._names, self.scale)
164
+
165
+ def _finish(self, parsed: list) -> SimilarityResult:
166
+ lo, hi = self.scale
167
+ raw_dims = {n: sum(p[0][n] for p in parsed) / len(parsed) for n in self._names}
168
+ norm = {n: (v - lo) / (hi - lo) for n, v in raw_dims.items()}
169
+ score = sum(self._weights[n] * norm[n] for n in self._names)
170
+ raw = lo + score * (hi - lo)
171
+ reason = next((p[1] for p in parsed if p[1]), None)
172
+ return SimilarityResult(score, raw=raw, reason=reason, metadata={"dimensions": norm})
173
+
174
+ def compute(self, a: Any, b: Any, *, context: Context = None) -> SimilarityResult:
175
+ if self._llm is None:
176
+ raise RuntimeError("this judge only has an async llm; use aevaluate()/ascore()")
177
+ orders = [(a, b), (b, a)] if self.symmetric else [(a, b)]
178
+ return self._finish([self._parse(self._llm(self._render(x, y, context))) for x, y in orders])
179
+
180
+ async def acompute(self, a: Any, b: Any, *, context: Context = None) -> SimilarityResult:
181
+ if self._allm is None:
182
+ return await super().acompute(a, b, context=context) # type: ignore[return-value]
183
+ orders = [(a, b), (b, a)] if self.symmetric else [(a, b)]
184
+ parsed = [self._parse(await self._allm(self._render(x, y, context))) for x, y in orders]
185
+ return self._finish(parsed)
@@ -0,0 +1,46 @@
1
+ """Similarity for *ordered* lists, where position matters (ranked retrieval, tool sequences)."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Hashable, List, Sequence
5
+
6
+ from .base import Context, Similarity
7
+
8
+
9
+ def _unique(seq: Sequence[Hashable]) -> List[Hashable]:
10
+ return list(dict.fromkeys(seq))
11
+
12
+
13
+ class RankBiasedOverlap(Similarity):
14
+ """Rank-biased overlap (Webber et al., 2010), extrapolated form on the common depth.
15
+
16
+ Top ranks count more than lower ones; ``p`` sets how top-heavy (0.9 means roughly the
17
+ top ~10 ranks carry most of the weight). Identical rankings score 1.0, disjoint 0.0.
18
+ Lists are truncated to the shorter length; duplicates keep their first position.
19
+ """
20
+
21
+ name = "rank_biased_overlap"
22
+
23
+ def __init__(self, p: float = 0.9) -> None:
24
+ if not 0.0 < p < 1.0:
25
+ raise ValueError("p must be in (0, 1)")
26
+ self.p = p
27
+
28
+ def compute(self, a: Any, b: Any, *, context: Context = None) -> float:
29
+ la, lb = _unique(list(a)), _unique(list(b))
30
+ if not la and not lb:
31
+ return 1.0
32
+ k = min(len(la), len(lb))
33
+ if k == 0:
34
+ return 0.0
35
+ p, seen_a, seen_b = self.p, set(), set()
36
+ overlap, weighted = 0, 0.0
37
+ for d in range(1, k + 1):
38
+ x, y = la[d - 1], lb[d - 1]
39
+ if x == y:
40
+ overlap += 1
41
+ else:
42
+ overlap += (x in seen_b) + (y in seen_a)
43
+ seen_a.add(x)
44
+ seen_b.add(y)
45
+ weighted += (overlap / d) * p**d
46
+ return (overlap / k) * p**k + ((1 - p) / p) * weighted
@@ -0,0 +1,53 @@
1
+ """Set-overlap similarities over collections of hashable items (IDs, labels, URLs...)."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Hashable, Iterable, Set
5
+
6
+ from .base import Context, Similarity
7
+
8
+
9
+ def to_set(x: Iterable[Hashable]) -> Set[Hashable]:
10
+ if isinstance(x, (str, bytes)):
11
+ raise TypeError(
12
+ "set similarities compare collections; got a bare string. "
13
+ "Wrap it (e.g. [text]) or use a text similarity such as NgramJaccard."
14
+ )
15
+ return set(x)
16
+
17
+
18
+ class JaccardSimilarity(Similarity):
19
+ """|A & B| / |A | B|. Two empty collections count as identical (1.0)."""
20
+
21
+ name = "jaccard"
22
+
23
+ def compute(self, a: Any, b: Any, *, context: Context = None) -> float:
24
+ sa, sb = to_set(a), to_set(b)
25
+ if not sa and not sb:
26
+ return 1.0
27
+ return len(sa & sb) / len(sa | sb)
28
+
29
+
30
+ class DiceSimilarity(Similarity):
31
+ """2|A & B| / (|A| + |B|): like Jaccard but more forgiving of partial overlap."""
32
+
33
+ name = "dice"
34
+
35
+ def compute(self, a: Any, b: Any, *, context: Context = None) -> float:
36
+ sa, sb = to_set(a), to_set(b)
37
+ if not sa and not sb:
38
+ return 1.0
39
+ return 2 * len(sa & sb) / (len(sa) + len(sb))
40
+
41
+
42
+ class OverlapCoefficient(Similarity):
43
+ """|A & B| / min(|A|, |B|): 1.0 when one collection is contained in the other."""
44
+
45
+ name = "overlap_coefficient"
46
+
47
+ def compute(self, a: Any, b: Any, *, context: Context = None) -> float:
48
+ sa, sb = to_set(a), to_set(b)
49
+ if not sa and not sb:
50
+ return 1.0
51
+ if not sa or not sb:
52
+ return 0.0
53
+ return len(sa & sb) / min(len(sa), len(sb))
@@ -0,0 +1,114 @@
1
+ """Lexical text similarities (no model calls)."""
2
+ from __future__ import annotations
3
+
4
+ import difflib
5
+ import re
6
+ from typing import Any, Callable, List, Optional, Sequence, Set, Tuple, Union
7
+
8
+ from .base import Context, Similarity
9
+
10
+ _TOKEN_RE = re.compile(r"\w+", re.UNICODE)
11
+
12
+
13
+ def tokenize(text: str) -> List[str]:
14
+ """Lower-cased word tokens (Unicode-aware, punctuation dropped)."""
15
+ return _TOKEN_RE.findall(text.lower())
16
+
17
+
18
+ def default_normalize(text: str) -> str:
19
+ """Case-fold and collapse whitespace."""
20
+ return " ".join(text.casefold().split())
21
+
22
+
23
+ def ngram_set(text: str, n: int = 3) -> Set[Tuple[str, ...]]:
24
+ """Word n-grams of ``text``. Text shorter than ``n`` yields one short gram."""
25
+ if n < 1:
26
+ raise ValueError("n must be >= 1")
27
+ tokens = tokenize(text)
28
+ if not tokens:
29
+ return set()
30
+ if len(tokens) < n:
31
+ return {tuple(tokens)}
32
+ return {tuple(tokens[k : k + n]) for k in range(len(tokens) - n + 1)}
33
+
34
+
35
+ class ExactMatch(Similarity):
36
+ """1.0 if the normalised strings are equal, else 0.0."""
37
+
38
+ name = "exact_match"
39
+
40
+ def __init__(self, normalize: Optional[Callable[[str], str]] = default_normalize) -> None:
41
+ self._normalize = normalize or (lambda s: s)
42
+
43
+ def compute(self, a: Any, b: Any, *, context: Context = None) -> float:
44
+ return 1.0 if self._normalize(str(a)) == self._normalize(str(b)) else 0.0
45
+
46
+
47
+ class NgramJaccard(Similarity):
48
+ """Jaccard over word n-grams.
49
+
50
+ Each item is a string or a sequence of strings (e.g. retrieved chunks); a sequence is
51
+ pooled into one n-gram set. That makes it robust to re-chunking: when chunk IDs
52
+ change but the underlying text does not, the score stays high.
53
+ """
54
+
55
+ name = "ngram_jaccard"
56
+
57
+ def __init__(self, n: int = 3) -> None:
58
+ if n < 1:
59
+ raise ValueError("n must be >= 1")
60
+ self.n = n
61
+
62
+ def _grams(self, item: Union[str, Sequence[str]]) -> Set[Tuple[str, ...]]:
63
+ texts = [item] if isinstance(item, str) else list(item)
64
+ grams: Set[Tuple[str, ...]] = set()
65
+ for t in texts:
66
+ grams |= ngram_set(t, self.n)
67
+ return grams
68
+
69
+ def compute(self, a: Any, b: Any, *, context: Context = None) -> float:
70
+ ga, gb = self._grams(a), self._grams(b)
71
+ if not ga and not gb:
72
+ return 1.0
73
+ return len(ga & gb) / len(ga | gb)
74
+
75
+
76
+ class RougeL(Similarity):
77
+ """ROUGE-L F-measure: longest common token subsequence, so word order matters."""
78
+
79
+ name = "rouge_l"
80
+
81
+ @staticmethod
82
+ def _lcs(x: List[str], y: List[str]) -> int:
83
+ if not x or not y:
84
+ return 0
85
+ prev = [0] * (len(y) + 1)
86
+ for xi in x:
87
+ cur = [0]
88
+ for j, yj in enumerate(y, 1):
89
+ cur.append(prev[j - 1] + 1 if xi == yj else max(prev[j], cur[j - 1]))
90
+ prev = cur
91
+ return prev[-1]
92
+
93
+ def compute(self, a: Any, b: Any, *, context: Context = None) -> float:
94
+ ta, tb = tokenize(str(a)), tokenize(str(b))
95
+ if not ta and not tb:
96
+ return 1.0
97
+ lcs = self._lcs(ta, tb)
98
+ if lcs == 0:
99
+ return 0.0
100
+ p, r = lcs / len(tb), lcs / len(ta)
101
+ return 2 * p * r / (p + r)
102
+
103
+
104
+ class EditSimilarity(Similarity):
105
+ """Character-level similarity ratio (``difflib``); also works on lists of tokens."""
106
+
107
+ name = "edit_similarity"
108
+
109
+ def compute(self, a: Any, b: Any, *, context: Context = None) -> float:
110
+ if isinstance(a, str):
111
+ a, b = default_normalize(a), default_normalize(str(b))
112
+ if len(a) == 0 and len(b) == 0:
113
+ return 1.0
114
+ return difflib.SequenceMatcher(None, a, b, autojunk=False).ratio()
@@ -0,0 +1,27 @@
1
+ """Small statistics helpers (standard library only)."""
2
+ from __future__ import annotations
3
+
4
+ import random
5
+ from typing import Sequence, Tuple
6
+
7
+
8
+ def mean(values: Sequence[float]) -> float:
9
+ if not values:
10
+ raise ValueError("mean() of an empty sequence")
11
+ return sum(values) / len(values)
12
+
13
+
14
+ def bootstrap_ci(
15
+ values: Sequence[float], n_boot: int = 2000, alpha: float = 0.05, seed: int = 0
16
+ ) -> Tuple[float, float]:
17
+ """Percentile bootstrap confidence interval for the mean of ``values``."""
18
+ if not values:
19
+ raise ValueError("bootstrap_ci() of an empty sequence")
20
+ if len(values) == 1:
21
+ return (values[0], values[0])
22
+ rng = random.Random(seed)
23
+ n = len(values)
24
+ means = sorted(sum(values[rng.randrange(n)] for _ in range(n)) / n for _ in range(n_boot))
25
+ lo = means[int((alpha / 2) * n_boot)]
26
+ hi = means[min(n_boot - 1, int((1 - alpha / 2) * n_boot))]
27
+ return (lo, hi)
@@ -0,0 +1,43 @@
1
+ """Result and data types shared across the package."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import asdict, dataclass, field
5
+ from typing import Any, Dict, List, Optional, Tuple
6
+
7
+ Pair = Tuple[int, int]
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class SimilarityResult:
12
+ """Outcome of comparing two items. ``score`` is always in [0, 1] (1 = identical)."""
13
+
14
+ score: float
15
+ raw: Optional[float] = None # the un-normalised value, e.g. a 1-5 judge rating
16
+ reason: Optional[str] = None # free-text rationale, e.g. from an LLM judge
17
+ metadata: Dict[str, Any] = field(default_factory=dict)
18
+
19
+
20
+ @dataclass
21
+ class PairScore:
22
+ i: int
23
+ j: int
24
+ score: float
25
+ raw: Optional[float] = None
26
+ reason: Optional[str] = None
27
+ dimensions: Optional[Dict[str, float]] = None # per-rubric-dimension scores in [0, 1]
28
+
29
+
30
+ @dataclass
31
+ class ConsistencyResult:
32
+ """Consistency of one group of N items (e.g. N runs of the same input)."""
33
+
34
+ metric: str
35
+ score: float # headline value in [0, 1]; 1 = fully consistent
36
+ min_score: float # the weakest compared pair (the "worst-case" view)
37
+ n_items: int
38
+ n_pairs: int # pairs actually compared (== number of similarity calls)
39
+ pairs: List[PairScore] = field(default_factory=list)
40
+ details: Dict[str, Any] = field(default_factory=dict)
41
+
42
+ def to_dict(self) -> Dict[str, Any]:
43
+ return asdict(self)
@@ -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