reliability-lab 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,68 @@
1
+ """reliability-lab: study design for the reliability of LLM and human judgments."""
2
+
3
+ from reliability_lab._version import __version__
4
+ from reliability_lab.alpha import INTERVAL, NOMINAL, ORDINAL, RATIO, Distance
5
+ from reliability_lab.data import RatingFrame, Status
6
+ from reliability_lab.design import CoderSpec, Design
7
+ from reliability_lab.exceptions import (
8
+ BootstrapWarning,
9
+ CategorySkewWarning,
10
+ CoderDependenceWarning,
11
+ DesignError,
12
+ DomainSizeWarning,
13
+ LeakageError,
14
+ ReliabilityLabError,
15
+ ReliabilityWarning,
16
+ SchemaError,
17
+ TraceWarning,
18
+ UndefinedAlphaError,
19
+ )
20
+ from reliability_lab.llm import TraceSidecar
21
+ from reliability_lab.report import (
22
+ AlphaEstimate,
23
+ AlphaResult,
24
+ BootstrapResult,
25
+ ComparisonResult,
26
+ Coverage,
27
+ Diagnostic,
28
+ Interval,
29
+ Provenance,
30
+ )
31
+ from reliability_lab.split import SplitManifest
32
+ from reliability_lab.study import Fold, ReliabilityStudy
33
+
34
+ __all__ = [
35
+ "INTERVAL",
36
+ "NOMINAL",
37
+ "ORDINAL",
38
+ "RATIO",
39
+ "AlphaEstimate",
40
+ "AlphaResult",
41
+ "BootstrapResult",
42
+ "BootstrapWarning",
43
+ "CategorySkewWarning",
44
+ "CoderDependenceWarning",
45
+ "CoderSpec",
46
+ "ComparisonResult",
47
+ "Coverage",
48
+ "Design",
49
+ "DesignError",
50
+ "Diagnostic",
51
+ "Distance",
52
+ "DomainSizeWarning",
53
+ "Fold",
54
+ "Interval",
55
+ "LeakageError",
56
+ "Provenance",
57
+ "RatingFrame",
58
+ "ReliabilityLabError",
59
+ "ReliabilityStudy",
60
+ "ReliabilityWarning",
61
+ "SchemaError",
62
+ "SplitManifest",
63
+ "Status",
64
+ "TraceSidecar",
65
+ "TraceWarning",
66
+ "UndefinedAlphaError",
67
+ "__version__",
68
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,290 @@
1
+ """Krippendorff's alpha from coincidence matrices.
2
+
3
+ Notation follows Krippendorff (2011), *Computing Krippendorff's alpha-reliability*:
4
+ ``o`` is the V x V coincidence matrix, ``n_c`` its marginals, ``n`` the number of
5
+ pairable values and ``delta2`` the squared distances between values.
6
+
7
+ D_o = sum(o * delta2) / n
8
+ D_e = sum(n_c n_k delta2) / (n (n - 1))
9
+ alpha = 1 - D_o / D_e
10
+
11
+ The coincidence matrix is a sum of per-unit contributions, and those do not depend
12
+ on the distance. Precomputing them once therefore serves every distance and every
13
+ bootstrap replicate. Only distances that depend on the marginals (ordinal) must be
14
+ rebuilt from each replicate's ``n_c``.
15
+
16
+ This module works on integer-coded numpy arrays only; ``study.py`` does the coding.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import Callable, Sequence
22
+ from dataclasses import dataclass
23
+ from typing import Any
24
+
25
+ import numpy as np
26
+ import numpy.typing as npt
27
+ from scipy import sparse
28
+
29
+ from reliability_lab.exceptions import DesignError
30
+
31
+ FloatArray = npt.NDArray[np.float64]
32
+ IntArray = npt.NDArray[np.intp]
33
+
34
+ #: Above this many declared values a DomainSizeWarning is raised.
35
+ DOMAIN_SIZE_WARN = 1_000
36
+ #: Above this many declared values the study is refused.
37
+ DOMAIN_SIZE_MAX = 10_000
38
+
39
+ #: ``fn(values, marginals) -> V x V`` squared distances over the declared domain.
40
+ DistanceFn = Callable[[npt.NDArray[Any], FloatArray], FloatArray]
41
+
42
+
43
+ @dataclass(frozen=True, eq=False)
44
+ class Distance:
45
+ """A squared-distance function delta² over a declared value domain.
46
+
47
+ ``fn`` receives the declared values (in declared order) and the marginal count
48
+ of each value, and returns a symmetric V x V matrix with a zero diagonal.
49
+ ``depends_on_marginals`` marks metrics such as ordinal whose distances change
50
+ with the category distribution. ``numeric`` marks metrics that need numbers.
51
+ """
52
+
53
+ name: str
54
+ fn: DistanceFn
55
+ depends_on_marginals: bool = False
56
+ numeric: bool = False
57
+
58
+ def matrix(self, values: Sequence[Any], marginals: npt.ArrayLike) -> FloatArray:
59
+ v = np.asarray(values, dtype=float if self.numeric else object)
60
+ m = np.asarray(marginals, dtype=float)
61
+ d = np.asarray(self.fn(v, m), dtype=float)
62
+ _validate_distance_matrix(d, len(values), self.name)
63
+ return d
64
+
65
+ @classmethod
66
+ def from_matrix(cls, name: str, matrix: npt.ArrayLike) -> Distance:
67
+ """A custom distance from a fixed V x V matrix of squared distances.
68
+
69
+ Rows and columns follow the order of the declared ``values``.
70
+ """
71
+ fixed = np.array(matrix, dtype=float)
72
+ if fixed.ndim != 2 or fixed.shape[0] != fixed.shape[1]:
73
+ raise DesignError(f"distance {name!r} must be a square matrix, got {fixed.shape}")
74
+ _validate_distance_matrix(fixed, fixed.shape[0], name)
75
+ fixed.setflags(write=False)
76
+
77
+ def fn(values: npt.NDArray[Any], marginals: FloatArray) -> FloatArray:
78
+ if len(values) != fixed.shape[0]:
79
+ raise DesignError(
80
+ f"distance {name!r} is {fixed.shape[0]} x {fixed.shape[0]} but the value "
81
+ f"domain has {len(values)} values"
82
+ )
83
+ return fixed
84
+
85
+ return cls(name=name, fn=fn)
86
+
87
+
88
+ def _validate_distance_matrix(d: FloatArray, size: int, name: str) -> None:
89
+ if d.shape != (size, size):
90
+ raise DesignError(f"distance {name!r} returned shape {d.shape}, expected ({size}, {size})")
91
+ if not np.isfinite(d).all():
92
+ raise DesignError(f"distance {name!r} has non-finite entries")
93
+ if (d < 0).any():
94
+ raise DesignError(f"distance {name!r} has negative entries")
95
+ if not np.allclose(d, d.T, rtol=1e-12, atol=1e-12):
96
+ raise DesignError(f"distance {name!r} is not symmetric")
97
+ if np.any(np.diag(d) != 0):
98
+ raise DesignError(f"distance {name!r} must be zero on the diagonal")
99
+
100
+
101
+ def _nominal(values: npt.NDArray[Any], marginals: FloatArray) -> FloatArray:
102
+ return 1.0 - np.eye(len(values))
103
+
104
+
105
+ def _ordinal(values: npt.NDArray[Any], marginals: FloatArray) -> FloatArray:
106
+ # delta_ck = sum_{g=c..k} n_g - (n_c + n_k)/2, which equals r_k - r_c with
107
+ # r = cumsum(n) - n/2 (the mid-rank of each value in declared order).
108
+ r = np.cumsum(marginals) - marginals / 2.0
109
+ return np.asarray((r[:, None] - r[None, :]) ** 2, dtype=float)
110
+
111
+
112
+ def _interval(values: npt.NDArray[Any], marginals: FloatArray) -> FloatArray:
113
+ v = values.astype(float)
114
+ return np.asarray((v[:, None] - v[None, :]) ** 2, dtype=float)
115
+
116
+
117
+ def _ratio(values: npt.NDArray[Any], marginals: FloatArray) -> FloatArray:
118
+ v = values.astype(float)
119
+ if (v < 0).any():
120
+ raise DesignError("ratio distances need non-negative values")
121
+ total = v[:, None] + v[None, :]
122
+ diff = v[:, None] - v[None, :]
123
+ safe = np.where(total == 0, 1.0, total)
124
+ return np.asarray(np.where(total == 0, 0.0, (diff / safe) ** 2), dtype=float)
125
+
126
+
127
+ NOMINAL = Distance("nominal", _nominal)
128
+ ORDINAL = Distance("ordinal", _ordinal, depends_on_marginals=True)
129
+ INTERVAL = Distance("interval", _interval, numeric=True)
130
+ RATIO = Distance("ratio", _ratio, numeric=True)
131
+
132
+ BUILTIN_DISTANCES: dict[str, Distance] = {d.name: d for d in (NOMINAL, ORDINAL, INTERVAL, RATIO)}
133
+
134
+
135
+ def check_domain_size(n_values: int) -> str | None:
136
+ """Refuse oversized domains; return a warning message for large ones."""
137
+ megabytes = n_values * n_values * 8 / 1e6
138
+ if n_values > DOMAIN_SIZE_MAX:
139
+ raise DesignError(
140
+ f"the value domain has {n_values} values (more than {DOMAIN_SIZE_MAX}); each "
141
+ f"V x V matrix would need about {megabytes:,.0f} MB"
142
+ )
143
+ if n_values > DOMAIN_SIZE_WARN:
144
+ return (
145
+ f"the value domain has {n_values} values; each V x V matrix needs about "
146
+ f"{megabytes:,.0f} MB"
147
+ )
148
+ return None
149
+
150
+
151
+ def per_unit_coincidences(
152
+ unit_idx: IntArray, value_idx: IntArray, n_units: int, n_values: int
153
+ ) -> tuple[sparse.csr_array, FloatArray]:
154
+ """Each unit's contribution to the coincidence matrix.
155
+
156
+ Row ``u`` of the returned ``(n_units, n_values**2)`` sparse array is unit u's
157
+ V x V contribution, flattened in C order: ``(n_u n_u' - diag(n_u)) / (m_u - 1)``.
158
+ Here ``n_u`` counts the unit's pairable values and ``m_u`` is their total.
159
+ Units with fewer than two pairable values contribute nothing. Summing the rows
160
+ (or weighting them, in a bootstrap) gives the coincidence matrix.
161
+
162
+ Returns the sparse array and ``m_u`` for every unit.
163
+ """
164
+ unit_idx = np.asarray(unit_idx, dtype=np.intp)
165
+ value_idx = np.asarray(value_idx, dtype=np.intp)
166
+ counts = sparse.csr_array(
167
+ (np.ones(len(unit_idx)), (unit_idx, value_idx)), shape=(n_units, n_values)
168
+ )
169
+ counts.sum_duplicates()
170
+ counts.sort_indices()
171
+ pairable = np.asarray(counts.sum(axis=1), dtype=float).ravel()
172
+
173
+ indptr = counts.indptr.astype(np.intp)
174
+ cols = counts.indices.astype(np.intp)
175
+ data = counts.data.astype(float)
176
+ distinct = np.diff(indptr)
177
+ # Pair every non-zero entry with every non-zero entry of the same unit.
178
+ entry_unit = np.repeat(np.arange(n_units), distinct)
179
+ reps = distinct[entry_unit]
180
+ left = np.repeat(np.arange(len(cols)), reps)
181
+ block_start = np.repeat(np.cumsum(reps) - reps, reps)
182
+ right = np.repeat(indptr[entry_unit], reps) + (np.arange(len(left)) - block_start)
183
+
184
+ unit = entry_unit[left]
185
+ same = cols[left] == cols[right]
186
+ weight = data[left] * data[right] - np.where(same, data[left], 0.0)
187
+ keep = (pairable[unit] >= 2) & (weight != 0)
188
+ contribution = weight[keep] / (pairable[unit][keep] - 1)
189
+ flat = cols[left][keep] * n_values + cols[right][keep]
190
+ out = sparse.csr_array((contribution, (unit[keep], flat)), shape=(n_units, n_values * n_values))
191
+ return out, pairable
192
+
193
+
194
+ def coincidence_matrix(per_unit: sparse.csr_array, n_values: int) -> FloatArray:
195
+ """Sum the per-unit contributions into the V x V coincidence matrix."""
196
+ flat = np.asarray(per_unit.sum(axis=0), dtype=float).ravel()
197
+ return flat.reshape(n_values, n_values)
198
+
199
+
200
+ @dataclass(frozen=True, eq=False)
201
+ class AlphaComponents:
202
+ """Alpha and the quantities it is computed from.
203
+
204
+ When alpha is undefined, ``alpha``, ``d_o`` and ``d_e`` hold what could be
205
+ computed (NaN otherwise) and ``undefined_reason`` says why. Nothing is raised
206
+ here, so bootstrap code can count failed replicates.
207
+ """
208
+
209
+ alpha: float
210
+ d_o: float
211
+ d_e: float
212
+ n: float
213
+ marginals: FloatArray
214
+ delta2: FloatArray
215
+ expected: FloatArray
216
+ undefined_reason: str | None
217
+
218
+ @property
219
+ def undefined(self) -> bool:
220
+ return self.undefined_reason is not None
221
+
222
+
223
+ def alpha_from_coincidences(
224
+ coincidence: FloatArray, values: Sequence[Any], distance: Distance
225
+ ) -> AlphaComponents:
226
+ """Alpha from a coincidence matrix over the declared ``values``."""
227
+ o = np.asarray(coincidence, dtype=float)
228
+ n_c = o.sum(axis=1)
229
+ n = float(n_c.sum())
230
+ delta2 = distance.matrix(values, n_c)
231
+ if n < 2:
232
+ nan = float("nan")
233
+ return AlphaComponents(
234
+ nan,
235
+ nan,
236
+ nan,
237
+ n,
238
+ n_c,
239
+ delta2,
240
+ np.zeros_like(o),
241
+ "no unit has two or more pairable values",
242
+ )
243
+ expected = (np.outer(n_c, n_c) - np.diag(n_c)) / (n - 1)
244
+ d_o = float((o * delta2).sum() / n)
245
+ d_e = float((expected * delta2).sum() / n)
246
+ if not d_e > 0:
247
+ return AlphaComponents(
248
+ float("nan"),
249
+ d_o,
250
+ d_e,
251
+ n,
252
+ n_c,
253
+ delta2,
254
+ expected,
255
+ "expected disagreement is zero (every pairable value is in one category or at "
256
+ "zero distance), so alpha is undefined",
257
+ )
258
+ return AlphaComponents(1.0 - d_o / d_e, d_o, d_e, n, n_c, delta2, expected, None)
259
+
260
+
261
+ @dataclass(frozen=True, eq=False)
262
+ class AlphaComputation:
263
+ """Alpha for one set of pairable values, with its white-box decomposition.
264
+
265
+ ``unit_disagreement[u]`` is ``sum(o_u * delta2)``. Divided by ``n`` it is unit
266
+ u's contribution to D_o (the contributions sum to D_o). Divided by ``m_u`` it is
267
+ the mean squared distance over the unit's ordered pairs of values.
268
+ """
269
+
270
+ components: AlphaComponents
271
+ coincidence: FloatArray
272
+ per_unit: sparse.csr_array
273
+ pairable: FloatArray
274
+ unit_disagreement: FloatArray
275
+
276
+
277
+ def compute_alpha(
278
+ unit_idx: IntArray,
279
+ value_idx: IntArray,
280
+ n_units: int,
281
+ values: Sequence[Any],
282
+ distance: Distance,
283
+ ) -> AlphaComputation:
284
+ """Alpha from integer-coded pairable values (one entry per rating)."""
285
+ n_values = len(values)
286
+ per_unit, pairable = per_unit_coincidences(unit_idx, value_idx, n_units, n_values)
287
+ o = coincidence_matrix(per_unit, n_values)
288
+ components = alpha_from_coincidences(o, values, distance)
289
+ unit_disagreement = np.asarray(per_unit @ components.delta2.ravel(), dtype=float)
290
+ return AlphaComputation(components, o, per_unit, pairable, unit_disagreement)
@@ -0,0 +1,294 @@
1
+ """Long-form ratings: one row per rating attempt, each with an explicit status."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from collections.abc import Hashable, Iterable
7
+ from enum import StrEnum
8
+ from typing import Any
9
+
10
+ import numpy as np
11
+ import numpy.typing as npt
12
+ import pandas as pd
13
+
14
+ from reliability_lab.exceptions import SchemaError
15
+
16
+
17
+ class Status(StrEnum):
18
+ """Outcome of one rating attempt."""
19
+
20
+ OBSERVED = "observed"
21
+ ABSTAINED = "abstained"
22
+ INAPPLICABLE = "inapplicable"
23
+ PARSE_ERROR = "parse_error"
24
+ PROVIDER_ERROR = "provider_error"
25
+
26
+
27
+ #: Statuses of failed attempts, as plain strings (the stored form).
28
+ ERROR_STATUSES: frozenset[str] = frozenset({Status.PARSE_ERROR.value, Status.PROVIDER_ERROR.value})
29
+
30
+ #: Canonical field names, in the order a RatingFrame stores them.
31
+ FIELDS: tuple[str, ...] = (
32
+ "unit_id",
33
+ "rater_id",
34
+ "value",
35
+ "status",
36
+ "group_id",
37
+ "stratum",
38
+ "model_id",
39
+ "model_family",
40
+ "condition_id",
41
+ "run_id",
42
+ "attempt",
43
+ )
44
+
45
+
46
+ def preview(labels: Iterable[Any], limit: int = 5) -> str:
47
+ """Format the first few labels of a collection for an error message."""
48
+ items = list(labels)
49
+ shown = ", ".join(repr(_plain(x)) for x in items[:limit])
50
+ more = f" and {len(items) - limit} more" if len(items) > limit else ""
51
+ return f"[{shown}]{more}"
52
+
53
+
54
+ def _plain(x: Any) -> Any:
55
+ """Turn numpy scalars into Python scalars so they print cleanly."""
56
+ return x.item() if isinstance(x, np.generic) else x
57
+
58
+
59
+ def canonical_text(x: Any) -> str:
60
+ """Stable text for fingerprints: missing is empty, integral floats print as ints."""
61
+ if x is None or (not isinstance(x, str) and bool(pd.isna(x))):
62
+ return ""
63
+ x = _plain(x)
64
+ if isinstance(x, float) and x.is_integer():
65
+ return str(int(x))
66
+ return str(x)
67
+
68
+
69
+ def fingerprint_rows(columns: dict[str, Iterable[Any]]) -> str:
70
+ """sha256 over rows of named columns, independent of row order."""
71
+ names = list(columns)
72
+ texts = [[canonical_text(v) for v in col] for col in columns.values()]
73
+ rows = sorted("\x1f".join(parts) for parts in zip(*texts, strict=True))
74
+ digest = hashlib.sha256("\x1f".join(names).encode("utf-8"))
75
+ for row in rows:
76
+ digest.update(b"\n" + row.encode("utf-8"))
77
+ return digest.hexdigest()
78
+
79
+
80
+ class RatingFrame:
81
+ """Validated long-form ratings.
82
+
83
+ Build one with :meth:`from_long`. Each row is one rating attempt by one rater
84
+ on one unit. Its ``status`` says whether a value was observed, the rater
85
+ abstained, the item was inapplicable, or the attempt failed (parse or provider
86
+ error). The frame is copied on construction and never mutated afterwards.
87
+ """
88
+
89
+ def __init__(self, data: pd.DataFrame, declared: frozenset[str], implicit_status: bool) -> None:
90
+ self._data = data
91
+ self._declared = declared
92
+ self._implicit_status = implicit_status
93
+ self._fingerprint: str | None = None
94
+
95
+ def fingerprint(self) -> str:
96
+ """sha256 of the validated ratings (declared fields only).
97
+
98
+ Rows are sorted before hashing, and values are written as canonical text,
99
+ so the fingerprint ignores row order and dtype changes such as ``1`` vs
100
+ ``1.0``.
101
+ """
102
+ if self._fingerprint is None:
103
+ fields = [f for f in FIELDS if f in self._declared]
104
+ self._fingerprint = fingerprint_rows({f: self._data[f] for f in fields})
105
+ return self._fingerprint
106
+
107
+ @classmethod
108
+ def from_long(
109
+ cls,
110
+ df: pd.DataFrame,
111
+ *,
112
+ unit: Hashable,
113
+ rater: Hashable,
114
+ value: Hashable,
115
+ status: Hashable | None = None,
116
+ group: Hashable | None = None,
117
+ stratum: Hashable | None = None,
118
+ model_id: Hashable | None = None,
119
+ model_family: Hashable | None = None,
120
+ condition_id: Hashable | None = None,
121
+ run_id: Hashable | None = None,
122
+ attempt: Hashable | None = None,
123
+ ) -> RatingFrame:
124
+ """Validate a long-form table and map its columns to canonical fields.
125
+
126
+ Each keyword names the column of ``df`` that holds that field. ``unit``,
127
+ ``rater`` and ``value`` are required. Without ``status`` every row must
128
+ carry a value and is recorded as observed. Pass a status column whenever
129
+ abstentions or failed attempts exist. ``attempt`` numbers the retries of
130
+ one call (1, 2, ...); a study then declares which attempt counts.
131
+ """
132
+ if not isinstance(df, pd.DataFrame):
133
+ raise TypeError(f"expected a pandas DataFrame, got {type(df).__name__}")
134
+ mapping: dict[str, Hashable | None] = {
135
+ "unit_id": unit,
136
+ "rater_id": rater,
137
+ "value": value,
138
+ "status": status,
139
+ "group_id": group,
140
+ "stratum": stratum,
141
+ "model_id": model_id,
142
+ "model_family": model_family,
143
+ "condition_id": condition_id,
144
+ "run_id": run_id,
145
+ "attempt": attempt,
146
+ }
147
+ absent = [c for c in mapping.values() if c is not None and c not in df.columns]
148
+ if absent:
149
+ raise SchemaError(f"columns not found in the ratings table: {preview(absent)}")
150
+
151
+ n = len(df)
152
+ columns: dict[str, Any] = {}
153
+ for field, column in mapping.items():
154
+ if column is None:
155
+ columns[field] = np.full(n, None, dtype=object)
156
+ else:
157
+ columns[field] = df[column].to_numpy(copy=True)
158
+ data = pd.DataFrame(columns, index=df.index)
159
+
160
+ for field in ("unit_id", "rater_id"):
161
+ _require_complete(data, field, mapping[field])
162
+
163
+ if status is None:
164
+ missing = data.index[data["value"].isna()]
165
+ if len(missing):
166
+ raise SchemaError(
167
+ f"value is missing in {len(missing)} rows {preview(missing)}. Pass status= "
168
+ "with a status column so that abstentions and failed attempts are explicit."
169
+ )
170
+ data["status"] = Status.OBSERVED.value
171
+ else:
172
+ data["status"] = _normalise_status(data["status"], status)
173
+ unobserved = data.index[(data["status"] == Status.OBSERVED) & data["value"].isna()]
174
+ if len(unobserved):
175
+ raise SchemaError(
176
+ f"{len(unobserved)} rows have status 'observed' but no value "
177
+ f"{preview(unobserved)}"
178
+ )
179
+
180
+ declared = frozenset(f for f, c in mapping.items() if c is not None)
181
+ for field in ("group_id", "stratum"):
182
+ if field in declared:
183
+ _require_complete(data, field, mapping[field])
184
+ _require_constant_within(data, field, "unit_id")
185
+ if "attempt" in declared:
186
+ _require_complete(data, "attempt", attempt)
187
+ numeric = pd.to_numeric(data["attempt"], errors="coerce")
188
+ bad = data.index[numeric.isna()]
189
+ if len(bad):
190
+ raise SchemaError(
191
+ f"attempt (column {attempt!r}) must be numeric; {len(bad)} rows are not "
192
+ f"{preview(bad)}"
193
+ )
194
+ data["attempt"] = numeric
195
+
196
+ is_llm = data["model_id"].notna()
197
+ if is_llm.any():
198
+ if "condition_id" not in declared:
199
+ raise SchemaError(
200
+ "LLM rows (non-null model_id) need condition_id: the frozen prompt, rubric, "
201
+ "parser, candidate order and generation settings of the judge."
202
+ )
203
+ bad = data.index[is_llm & data["condition_id"].isna()]
204
+ if len(bad):
205
+ raise SchemaError(f"{len(bad)} LLM rows have no condition_id {preview(bad)}")
206
+ if "model_family" in declared:
207
+ _require_constant_within(data[is_llm], "model_family", "model_id")
208
+
209
+ return cls(data, declared | {"status"}, implicit_status=status is None)
210
+
211
+ def __len__(self) -> int:
212
+ return len(self._data)
213
+
214
+ def __repr__(self) -> str:
215
+ counts = self._data["status"].value_counts()
216
+ status = ", ".join(f"{k}={int(v)}" for k, v in counts.items())
217
+ return (
218
+ f"RatingFrame(rows={len(self)}, units={self.n_units}, "
219
+ f"raters={self._data['rater_id'].nunique()}, status: {status})"
220
+ )
221
+
222
+ @property
223
+ def declared(self) -> frozenset[str]:
224
+ """Canonical fields that were mapped to a column (``status`` always counts)."""
225
+ return self._declared
226
+
227
+ @property
228
+ def implicit_status(self) -> bool:
229
+ """True when no status column was given and every row was taken as observed."""
230
+ return self._implicit_status
231
+
232
+ @property
233
+ def n_units(self) -> int:
234
+ return int(self._data["unit_id"].nunique())
235
+
236
+ @property
237
+ def is_llm(self) -> npt.NDArray[np.bool_]:
238
+ """Mask of rows produced by an LLM judge (rows with a model_id)."""
239
+ return self._data["model_id"].notna().to_numpy()
240
+
241
+ def to_frame(self) -> pd.DataFrame:
242
+ """A copy of the validated table, with canonical column names."""
243
+ return self._data.copy()
244
+
245
+ def _subset(self, mask: npt.NDArray[np.bool_]) -> RatingFrame:
246
+ """The rows selected by a boolean mask. Every row and per-unit check that
247
+ held for the whole frame still holds for a subset, so nothing is re-run."""
248
+ return RatingFrame(self._data[mask].copy(), self._declared, self._implicit_status)
249
+
250
+ def observed_values(self) -> list[Any]:
251
+ """Distinct observed values, sorted when they are mutually comparable.
252
+
253
+ Useful for declaring a value domain explicitly:
254
+ ``values=ratings.observed_values()``.
255
+ """
256
+ observed = self._data.loc[self._data["status"] == Status.OBSERVED, "value"]
257
+ unique = [_plain(v) for v in pd.unique(observed)]
258
+ try:
259
+ return sorted(unique)
260
+ except TypeError:
261
+ return unique
262
+
263
+
264
+ def _require_complete(data: pd.DataFrame, field: str, column: Hashable | None) -> None:
265
+ missing = data.index[data[field].isna()]
266
+ if len(missing):
267
+ raise SchemaError(
268
+ f"{field} (column {column!r}) is missing in {len(missing)} rows {preview(missing)}"
269
+ )
270
+
271
+
272
+ def _require_constant_within(data: pd.DataFrame, field: str, key: str) -> None:
273
+ per_key = data.groupby(key, sort=False)[field].nunique()
274
+ bad = per_key.index[per_key > 1]
275
+ if len(bad):
276
+ raise SchemaError(
277
+ f"each {key} must have exactly one {field}; {len(bad)} have several {preview(bad)}"
278
+ )
279
+
280
+
281
+ def _normalise_status(raw: pd.Series[Any], column: Hashable) -> pd.Series[Any]:
282
+ missing = raw.index[raw.isna()]
283
+ if len(missing):
284
+ raise SchemaError(
285
+ f"status (column {column!r}) is missing in {len(missing)} rows {preview(missing)}"
286
+ )
287
+ labels = raw.map(str)
288
+ legal = {s.value for s in Status}
289
+ unknown = sorted(set(labels[~labels.isin(legal)]))
290
+ if unknown:
291
+ raise SchemaError(
292
+ f"unknown status labels {preview(unknown)}; expected one of {sorted(legal)}"
293
+ )
294
+ return labels.astype(object)