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,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)
@@ -0,0 +1,223 @@
1
+ """Consistency metrics: turn a Similarity (or an equality test) into a score for N items.
2
+
3
+ A metric knows nothing about *what* the items are. Feed it the N final answers of N runs,
4
+ N sets of retrieved chunk IDs, N tool-call traces, N paraphrase-conditioned outputs... and
5
+ pick the Similarity that suits them.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import math
11
+ from abc import ABC, abstractmethod
12
+ from typing import Any, Callable, List, Optional, Sequence
13
+
14
+ from .sampling import CycleSampler, PairSampler
15
+ from .similarity.base import Context, Similarity, as_similarity
16
+ from .similarity.text import default_normalize
17
+ from .stats import mean
18
+ from .types import ConsistencyResult, PairScore, SimilarityResult
19
+
20
+
21
+ class ConsistencyMetric(ABC):
22
+ """Score how consistent a group of N items is. Higher is more consistent (1.0 = fully)."""
23
+
24
+ name: str = "consistency"
25
+
26
+ @abstractmethod
27
+ def evaluate(self, items: Sequence[Any], *, context: Context = None) -> ConsistencyResult: ...
28
+
29
+ async def aevaluate(
30
+ self, items: Sequence[Any], *, context: Context = None
31
+ ) -> ConsistencyResult:
32
+ return await asyncio.to_thread(self.evaluate, items, context=context)
33
+
34
+
35
+ def _degenerate(name: str, n_items: int) -> ConsistencyResult:
36
+ # Nothing to disagree with: zero or one item is trivially consistent.
37
+ return ConsistencyResult(name, 1.0, 1.0, n_items, 0, [], {"degenerate": True})
38
+
39
+
40
+ def _pair_score(i: int, j: int, r: SimilarityResult) -> PairScore:
41
+ return PairScore(i, j, r.score, r.raw, r.reason, r.metadata.get("dimensions"))
42
+
43
+
44
+ class PairwiseConsistency(ConsistencyMetric):
45
+ """Average similarity over a sampled set of item pairs.
46
+
47
+ Args:
48
+ similarity: a ``Similarity`` (or any ``fn(a, b) -> float``).
49
+ sampler: which pairs to compare. Default ``CycleSampler()``: N pairs, every item
50
+ covered, deterministic. Use ``AllPairs()`` for the exact all-pairs value.
51
+ preprocess: applied to each item before comparing, e.g. ``lambda r: r["chunk_ids"]``.
52
+ agreement_threshold: if set, ``details["agreement_rate"]`` reports the share of
53
+ compared pairs scoring at least this (on the normalised [0, 1] scale).
54
+ aggregate: how to combine pair scores into ``score`` (default: mean).
55
+ concurrency: max simultaneous similarity calls in ``aevaluate``.
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ similarity: Any,
61
+ *,
62
+ sampler: Optional[PairSampler] = None,
63
+ preprocess: Optional[Callable[[Any], Any]] = None,
64
+ agreement_threshold: Optional[float] = None,
65
+ aggregate: Optional[Callable[[Sequence[float]], float]] = None,
66
+ name: Optional[str] = None,
67
+ concurrency: int = 8,
68
+ ) -> None:
69
+ self.similarity: Similarity = as_similarity(similarity)
70
+ self.sampler = sampler or CycleSampler()
71
+ self.preprocess = preprocess
72
+ self.agreement_threshold = agreement_threshold
73
+ self.aggregate = aggregate or mean
74
+ self.name = name or f"pairwise_{self.similarity.name}"
75
+ self.concurrency = concurrency
76
+
77
+ def _prepare(self, items: Sequence[Any]) -> List[Any]:
78
+ return [self.preprocess(x) for x in items] if self.preprocess else list(items)
79
+
80
+ def _finish(self, n_items: int, pairs: List[PairScore]) -> ConsistencyResult:
81
+ scores = [p.score for p in pairs]
82
+ details = {"similarity": self.similarity.name, "sampler": type(self.sampler).__name__}
83
+ if self.agreement_threshold is not None:
84
+ details["agreement_rate"] = sum(s >= self.agreement_threshold for s in scores) / len(scores)
85
+ dims = [p.dimensions for p in pairs if p.dimensions]
86
+ if dims: # rubric judges: mean score per dimension, so you can see *what* varies
87
+ details["dimensions"] = {k: mean([d[k] for d in dims]) for k in dims[0]}
88
+ return ConsistencyResult(
89
+ self.name, self.aggregate(scores), min(scores), n_items, len(pairs), pairs, details
90
+ )
91
+
92
+ def evaluate(self, items: Sequence[Any], *, context: Context = None) -> ConsistencyResult:
93
+ prepared = self._prepare(items)
94
+ pairs = self.sampler.sample(len(prepared))
95
+ if not pairs:
96
+ return _degenerate(self.name, len(prepared))
97
+ scored = [
98
+ _pair_score(i, j, self.similarity.score(prepared[i], prepared[j], context=context))
99
+ for i, j in pairs
100
+ ]
101
+ return self._finish(len(prepared), scored)
102
+
103
+ async def aevaluate(
104
+ self, items: Sequence[Any], *, context: Context = None
105
+ ) -> ConsistencyResult:
106
+ prepared = self._prepare(items)
107
+ pairs = self.sampler.sample(len(prepared))
108
+ if not pairs:
109
+ return _degenerate(self.name, len(prepared))
110
+ sem = asyncio.Semaphore(self.concurrency)
111
+
112
+ async def one(i: int, j: int) -> PairScore:
113
+ async with sem:
114
+ r = await self.similarity.ascore(prepared[i], prepared[j], context=context)
115
+ return _pair_score(i, j, r)
116
+
117
+ scored = await asyncio.gather(*(one(i, j) for i, j in pairs))
118
+ return self._finish(len(prepared), list(scored))
119
+
120
+
121
+ class ClusterConsistency(ConsistencyMetric):
122
+ """Group items into 'same answer' clusters; the score is the largest cluster's share.
123
+
124
+ Greedy: each item joins the first cluster whose first member it matches
125
+ (``similarity >= threshold``), else starts a new one. Order-dependent by construction;
126
+ it never compares more than items x clusters pairs. ``details`` reports the cluster
127
+ sizes and the normalised entropy of the partition (the discrete form of semantic
128
+ entropy: 0 = one cluster, 1 = every item alone).
129
+ """
130
+
131
+ def __init__(
132
+ self, similarity: Any, *, threshold: float = 0.75, name: Optional[str] = None,
133
+ preprocess: Optional[Callable[[Any], Any]] = None, concurrency: int = 8,
134
+ ) -> None:
135
+ if not 0.0 <= threshold <= 1.0:
136
+ raise ValueError("threshold must be in [0, 1]")
137
+ self.similarity: Similarity = as_similarity(similarity)
138
+ self.threshold, self.preprocess, self.concurrency = threshold, preprocess, concurrency
139
+ self.name = name or f"cluster_{self.similarity.name}"
140
+
141
+ def _prepare(self, items: Sequence[Any]) -> List[Any]:
142
+ return [self.preprocess(x) for x in items] if self.preprocess else list(items)
143
+
144
+ def _result(self, n: int, members: List[List[int]], pairs: List[PairScore]) -> ConsistencyResult:
145
+ sizes = sorted((len(m) for m in members), reverse=True)
146
+ probs = [s / n for s in sizes]
147
+ entropy = -sum(p * math.log(p) for p in probs)
148
+ norm = entropy / math.log(n) if n > 1 else 0.0
149
+ details = {
150
+ "similarity": self.similarity.name, "threshold": self.threshold,
151
+ "cluster_sizes": sizes, "n_clusters": len(sizes), "normalized_entropy": norm,
152
+ "clusters": members,
153
+ }
154
+ scores = [p.score for p in pairs]
155
+ return ConsistencyResult(
156
+ self.name, sizes[0] / n, min(scores) if scores else 1.0, n, len(pairs), pairs, details
157
+ )
158
+
159
+ def evaluate(self, items: Sequence[Any], *, context: Context = None) -> ConsistencyResult:
160
+ prepared = self._prepare(items)
161
+ if len(prepared) < 2:
162
+ return _degenerate(self.name, len(prepared))
163
+ members: List[List[int]] = []
164
+ pairs: List[PairScore] = []
165
+ for idx, item in enumerate(prepared):
166
+ for cluster in members:
167
+ r = self.similarity.score(prepared[cluster[0]], item, context=context)
168
+ pairs.append(_pair_score(cluster[0], idx, r))
169
+ if r.score >= self.threshold:
170
+ cluster.append(idx)
171
+ break
172
+ else:
173
+ members.append([idx])
174
+ return self._result(len(prepared), members, pairs)
175
+
176
+ async def aevaluate(
177
+ self, items: Sequence[Any], *, context: Context = None
178
+ ) -> ConsistencyResult:
179
+ prepared = self._prepare(items)
180
+ if len(prepared) < 2:
181
+ return _degenerate(self.name, len(prepared))
182
+ sem = asyncio.Semaphore(self.concurrency)
183
+ members: List[List[int]] = []
184
+ pairs: List[PairScore] = []
185
+
186
+ async def cmp(rep: int, idx: int) -> PairScore:
187
+ async with sem:
188
+ r = await self.similarity.ascore(prepared[rep], prepared[idx], context=context)
189
+ return _pair_score(rep, idx, r)
190
+
191
+ for idx in range(len(prepared)):
192
+ # compare against every current cluster at once, then take the first match
193
+ results = await asyncio.gather(*(cmp(c[0], idx) for c in members))
194
+ pairs.extend(results)
195
+ hit = next((k for k, p in enumerate(results) if p.score >= self.threshold), None)
196
+ if hit is None:
197
+ members.append([idx])
198
+ else:
199
+ members[hit].append(idx)
200
+ return self._result(len(prepared), members, pairs)
201
+
202
+
203
+ class TotalAgreement(ConsistencyMetric):
204
+ """1.0 if *all* items are identical after normalisation, else 0.0.
205
+
206
+ The mean over a dataset is the total agreement rate (TAR@N of Atil et al., 2024).
207
+ Pass ``normalize=str`` for raw-output agreement, or an answer extractor for parsed
208
+ agreement.
209
+ """
210
+
211
+ name = "total_agreement"
212
+
213
+ def __init__(self, normalize: Callable[[Any], Any] = default_normalize) -> None:
214
+ self.normalize = normalize
215
+
216
+ def evaluate(self, items: Sequence[Any], *, context: Context = None) -> ConsistencyResult:
217
+ if len(items) < 2:
218
+ return _degenerate(self.name, len(items))
219
+ distinct = {self.normalize(x) for x in items}
220
+ score = 1.0 if len(distinct) == 1 else 0.0
221
+ return ConsistencyResult(
222
+ self.name, score, score, len(items), 0, [], {"n_distinct": len(distinct)}
223
+ )
@@ -0,0 +1,120 @@
1
+ """Default prompt for the LLM judge. Placeholders: {query}, {item_a}, {item_b}."""
2
+
3
+ SEMANTIC_EQUIVALENCE_PROMPT = """\
4
+ You are comparing two responses that were produced independently for the same input. \
5
+ Decide how closely they agree in meaning.
6
+
7
+ Input (may be empty):
8
+ {query}
9
+
10
+ Response A:
11
+ {item_a}
12
+
13
+ Response B:
14
+ {item_b}
15
+
16
+ Rate their semantic equivalence on a 1-5 scale:
17
+ 5 = Identical meaning: same core facts, no meaningful differences
18
+ 4 = Very similar: only minor phrasing or presentational differences
19
+ 3 = Partially equivalent: some differences in detail or completeness
20
+ 2 = Mostly different: notable factual or informational differences
21
+ 1 = Completely different information: little to no overlap
22
+
23
+ Guidelines:
24
+ - Judge meaning, not wording, length, formatting, or the order of points.
25
+ - Do not judge which response is better or more correct. Only judge whether they agree; \
26
+ two equally wrong responses that say the same thing score 5.
27
+ - Extra detail in one response lowers the score only when it adds or changes substantive \
28
+ information that the other lacks or contradicts.
29
+
30
+ Reply with JSON only: {"reason": "<one or two sentences>", "score": <integer 1-5>}
31
+ """
32
+
33
+
34
+ # --------------------------------------------------------------------------------------
35
+ # Multi-dimension rubric. One LLM call scores each dimension 1-5; ``RubricJudge`` combines
36
+ # them with the weights below. Edit, drop or add dimensions by passing your own ``Dimension``s.
37
+ # --------------------------------------------------------------------------------------
38
+ from dataclasses import dataclass # noqa: E402
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Dimension:
43
+ name: str
44
+ description: str # what 5 vs 1 means; shown to the judge verbatim
45
+ weight: float = 1.0
46
+
47
+
48
+ DEFAULT_DIMENSIONS = (
49
+ Dimension(
50
+ "core_facts",
51
+ "Do both responses make the same central claims, conclusions or answer? "
52
+ "5 = same core answer; 3 = same topic but the main conclusion is partly different or "
53
+ "one omits part of it; 1 = different or contradictory core answers.",
54
+ 0.40,
55
+ ),
56
+ Dimension(
57
+ "specifics",
58
+ "Do the concrete specifics agree: names, numbers, dates, versions, identifiers, "
59
+ "quantities, units, cited sources? "
60
+ "5 = all shared specifics match; 3 = some differ or are missing; "
61
+ "1 = specifics conflict. If neither response contains specifics, score 5.",
62
+ 0.25,
63
+ ),
64
+ Dimension(
65
+ "supporting_detail",
66
+ "Do the non-core details agree: explanations, examples, caveats, background, extra "
67
+ "context? 5 = same supporting information; 3 = noticeable additions or omissions; "
68
+ "1 = largely different supporting material. Differences here matter less than core facts.",
69
+ 0.15,
70
+ ),
71
+ Dimension(
72
+ "stance",
73
+ "Do the responses take the same stance and certainty: direct answer vs hedge vs refusal "
74
+ "vs 'I don't know', the same recommendation or next step, similar confidence? "
75
+ "5 = same stance; 3 = same direction but different confidence or caveats; "
76
+ "1 = opposite stance (e.g. one answers, one declines).",
77
+ 0.10,
78
+ ),
79
+ Dimension(
80
+ "format",
81
+ "Do the responses have the same form: structure (list vs prose vs table), length, "
82
+ "ordering, level of verbosity? 5 = essentially the same form; 3 = noticeably different "
83
+ "structure or length; 1 = completely different form. Ignore wording that doesn't change "
84
+ "structure.",
85
+ 0.10,
86
+ ),
87
+ )
88
+
89
+ RUBRIC_PROMPT_TEMPLATE = """\
90
+ You are comparing two responses that were produced independently for the same input. \
91
+ Judge how closely they agree on each dimension below.
92
+
93
+ Input (may be empty):
94
+ {query}
95
+
96
+ Response A:
97
+ {item_a}
98
+
99
+ Response B:
100
+ {item_b}
101
+
102
+ Score each dimension from 1 (completely different) to 5 (identical):
103
+ {rubric}
104
+
105
+ Guidelines:
106
+ - Judge agreement between the two responses only. Do not judge which one is better or more \
107
+ correct; two equally wrong responses that say the same thing agree.
108
+ - Score each dimension independently. A formatting difference must not lower core_facts, and \
109
+ vice versa.
110
+ - Judge meaning, not surface wording.
111
+
112
+ Reply with JSON only: \
113
+ {"reason": "<one or two sentences on the main differences>", "scores": {%s}}
114
+ """
115
+
116
+
117
+ def build_rubric_prompt(dimensions=DEFAULT_DIMENSIONS) -> str:
118
+ rubric = "\n".join(f"- {d.name}: {d.description}" for d in dimensions)
119
+ keys = ", ".join(f'"{d.name}": <integer 1-5>' for d in dimensions)
120
+ return RUBRIC_PROMPT_TEMPLATE.replace("{rubric}", rubric).replace("%s", keys)
File without changes
@@ -0,0 +1,102 @@
1
+ """Choosing which pairs of items to compare.
2
+
3
+ Comparing all N-choose-2 pairs is quadratic; with an LLM judge that gets expensive. The
4
+ samplers here pick a subset deterministically (seeded) so results are reproducible.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import itertools
9
+ import random
10
+ from dataclasses import dataclass
11
+ from typing import List, Optional, Protocol
12
+
13
+ from .types import Pair
14
+
15
+
16
+ class PairSampler(Protocol):
17
+ def sample(self, n_items: int) -> List[Pair]:
18
+ """Return distinct (i, j) pairs with i < j, drawn from range(n_items)."""
19
+ ...
20
+
21
+
22
+ def _norm(a: int, b: int) -> Pair:
23
+ return (a, b) if a < b else (b, a)
24
+
25
+
26
+ def cycle_pairs(n_items: int, budget: Optional[int] = None, seed: int = 0) -> List[Pair]:
27
+ """A random Hamiltonian cycle over the items, topped up with random extra pairs.
28
+
29
+ Every item appears in at least two sampled pairs when ``budget >= n_items``, so no
30
+ item can hide from the comparison. ``budget`` defaults to ``n_items``. When the
31
+ budget is smaller than the cycle, alternate cycle edges (a matching) are used first
32
+ so as many items as possible are still covered. When it reaches N-choose-2 you get
33
+ every pair.
34
+ """
35
+ if n_items < 2:
36
+ return []
37
+ all_pairs = list(itertools.combinations(range(n_items), 2))
38
+ budget = n_items if budget is None else budget
39
+ if budget < 1:
40
+ raise ValueError("budget must be >= 1")
41
+ if budget >= len(all_pairs):
42
+ return all_pairs
43
+
44
+ rng = random.Random(seed)
45
+ order = list(range(n_items))
46
+ rng.shuffle(order) # random cycle: no positional bias
47
+ ring = list(dict.fromkeys(_norm(order[k], order[(k + 1) % n_items]) for k in range(n_items)))
48
+ ring = ring[0::2] + ring[1::2] # even edges form a matching -> best coverage if truncated
49
+ chosen = ring[:budget]
50
+ if len(chosen) < budget:
51
+ taken = set(chosen)
52
+ rest = [p for p in all_pairs if p not in taken]
53
+ rng.shuffle(rest)
54
+ chosen += rest[: budget - len(chosen)]
55
+ return sorted(chosen)
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class AllPairs:
60
+ """Exact: every pair. Cost grows quadratically."""
61
+
62
+ def sample(self, n_items: int) -> List[Pair]:
63
+ return list(itertools.combinations(range(n_items), 2))
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class CycleSampler:
68
+ """Default. ``budget`` pairs (default: N) with coverage guaranteed; see ``cycle_pairs``."""
69
+
70
+ budget: Optional[int] = None
71
+ seed: int = 0
72
+
73
+ def sample(self, n_items: int) -> List[Pair]:
74
+ return cycle_pairs(n_items, self.budget, self.seed)
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class RandomPairs:
79
+ """``budget`` distinct pairs drawn uniformly, with no coverage guarantee."""
80
+
81
+ budget: int
82
+ seed: int = 0
83
+
84
+ def sample(self, n_items: int) -> List[Pair]:
85
+ all_pairs = list(itertools.combinations(range(n_items), 2))
86
+ if self.budget >= len(all_pairs):
87
+ return all_pairs
88
+ return sorted(random.Random(self.seed).sample(all_pairs, self.budget))
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class AnchorPairs:
93
+ """Compare every item to one anchor (e.g. a reference or baseline run): N-1 pairs."""
94
+
95
+ anchor: int = 0
96
+
97
+ def sample(self, n_items: int) -> List[Pair]:
98
+ if n_items < 2:
99
+ return []
100
+ if not 0 <= self.anchor < n_items:
101
+ raise IndexError(f"anchor {self.anchor} out of range for {n_items} items")
102
+ return [_norm(self.anchor, k) for k in range(n_items) if k != self.anchor]
@@ -0,0 +1,14 @@
1
+ from .base import FunctionSimilarity, Similarity, as_similarity
2
+ from .embedding import EmbeddingCosine
3
+ from .judge import JudgeParseError, LLMJudge, RubricJudge, parse_judge_output, parse_rubric_output
4
+ from .ranking import RankBiasedOverlap
5
+ from .sets import DiceSimilarity, JaccardSimilarity, OverlapCoefficient
6
+ from .text import EditSimilarity, ExactMatch, NgramJaccard, RougeL, ngram_set, tokenize
7
+
8
+ __all__ = [
9
+ "Similarity", "FunctionSimilarity", "as_similarity",
10
+ "JaccardSimilarity", "DiceSimilarity", "OverlapCoefficient",
11
+ "ExactMatch", "NgramJaccard", "RougeL", "EditSimilarity", "ngram_set", "tokenize",
12
+ "RankBiasedOverlap", "EmbeddingCosine",
13
+ "LLMJudge", "RubricJudge", "JudgeParseError", "parse_judge_output", "parse_rubric_output",
14
+ ]
@@ -0,0 +1,85 @@
1
+ """The one abstraction everything else is built on: comparing two items."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import math
6
+ from abc import ABC, abstractmethod
7
+ from typing import Any, Awaitable, Callable, Mapping, Optional, Union
8
+
9
+ from ..types import SimilarityResult
10
+
11
+ Context = Optional[Mapping[str, Any]]
12
+ _EPS = 1e-9
13
+
14
+
15
+ def coerce_result(value: Union[float, int, SimilarityResult], source: str) -> SimilarityResult:
16
+ """Accept a bare number or a SimilarityResult; enforce the [0, 1] contract."""
17
+ result = value if isinstance(value, SimilarityResult) else SimilarityResult(score=float(value))
18
+ s = result.score
19
+ if math.isnan(s) or s < -_EPS or s > 1 + _EPS:
20
+ raise ValueError(f"{source} returned {s!r}; similarity scores must be in [0, 1]")
21
+ if s != min(max(s, 0.0), 1.0):
22
+ result = SimilarityResult(min(max(s, 0.0), 1.0), result.raw, result.reason, result.metadata)
23
+ return result
24
+
25
+
26
+ class Similarity(ABC):
27
+ """Compare two items and return how alike they are, in [0, 1] (1 = identical).
28
+
29
+ Subclass and implement ``compute``. Return a float, or a ``SimilarityResult`` to
30
+ attach a raw value or a rationale. Items can be anything (strings, sets of IDs,
31
+ lists of chunks, tool-call traces); ``context`` carries side information such as the
32
+ query both items answer. Override ``acompute`` if your similarity does async I/O.
33
+ """
34
+
35
+ name: str = "similarity"
36
+
37
+ @abstractmethod
38
+ def compute(
39
+ self, a: Any, b: Any, *, context: Context = None
40
+ ) -> Union[float, SimilarityResult]: ...
41
+
42
+ async def acompute(
43
+ self, a: Any, b: Any, *, context: Context = None
44
+ ) -> Union[float, SimilarityResult]:
45
+ return await asyncio.to_thread(self.compute, a, b, context=context)
46
+
47
+ # public entry points -------------------------------------------------
48
+ def score(self, a: Any, b: Any, *, context: Context = None) -> SimilarityResult:
49
+ return coerce_result(self.compute(a, b, context=context), self.name)
50
+
51
+ async def ascore(self, a: Any, b: Any, *, context: Context = None) -> SimilarityResult:
52
+ return coerce_result(await self.acompute(a, b, context=context), self.name)
53
+
54
+ def __call__(self, a: Any, b: Any, *, context: Context = None) -> float:
55
+ return self.score(a, b, context=context).score
56
+
57
+
58
+ class FunctionSimilarity(Similarity):
59
+ """Wrap a plain ``fn(a, b) -> float`` (and optionally an async twin) as a Similarity."""
60
+
61
+ def __init__(
62
+ self,
63
+ fn: Callable[..., Union[float, SimilarityResult]],
64
+ *,
65
+ afn: Optional[Callable[..., Awaitable[Union[float, SimilarityResult]]]] = None,
66
+ name: Optional[str] = None,
67
+ ) -> None:
68
+ self._fn, self._afn = fn, afn
69
+ self.name = name or getattr(fn, "__name__", "function_similarity")
70
+
71
+ def compute(self, a: Any, b: Any, *, context: Context = None):
72
+ return self._fn(a, b)
73
+
74
+ async def acompute(self, a: Any, b: Any, *, context: Context = None):
75
+ if self._afn is not None:
76
+ return await self._afn(a, b)
77
+ return await super().acompute(a, b, context=context)
78
+
79
+
80
+ def as_similarity(obj: Any) -> Similarity:
81
+ if isinstance(obj, Similarity):
82
+ return obj
83
+ if callable(obj):
84
+ return FunctionSimilarity(obj)
85
+ raise TypeError(f"expected a Similarity or a callable, got {type(obj).__name__}")