Skip to content

Commit e77626b

Browse files
fix(scoring): pass limitations to C1 + judge only C2-owned dimensions
Closes out methodology review findings M-1 and M-2. M-1: _score_c1_grounding never passed the output's limitations list to score_facts, even though the scorer documents it as a fallback search target. A gold-fact value stated only in a caveat ("only 977 students are reflected in this snapshot") was scored as not found. Now passed through. M-2: the C2 loop judged all six rubric dimensions per run, then three (grounding_accuracy, calibration_limitation_handling, consistency) were discarded and overridden by C1/C3/C4 — 390 wasted judge calls per model at 26 tasks x 5 runs. score_rubric gains a dimensions parameter (full-rubric weight validation unchanged; unknown names raise before any judge call) and the dispatcher requests only the three C2-owned dimensions, halving judge cost. Default score_rubric behavior (all six) is unchanged for existing callers.
1 parent 1569186 commit e77626b

4 files changed

Lines changed: 113 additions & 21 deletions

File tree

benchmark/rubrics/rubric_scoring.py

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@
1212
cannot express cross-field numeric constraints. Any rubric whose dimension
1313
weights do not sum to approximately 1.0 (within ``WEIGHT_SUM_TOLERANCE``) is
1414
rejected with :exc:`ValueError` before any judge calls are made.
15-
- **Judge calls are capped** at one call per dimension (six total) per task,
16-
providing a deterministic upper bound on API usage.
15+
- **Judge calls are capped** at one call per scored dimension, providing a
16+
deterministic upper bound on API usage. Callers may restrict scoring to a
17+
subset of dimensions via the ``dimensions`` parameter; the runner judges
18+
only the three C2-owned dimensions, since the other three are computed
19+
deterministically by C1/C3/C4.
1720
- **Temperature 0 and a fixed random seed** are passed through to the judge
1821
client to ensure reproducible results; see :mod:`benchmark.rubrics.judge_client`
1922
for the live-judge integration wrapper.
@@ -145,6 +148,7 @@ def score_rubric(
145148
task: dict[str, Any],
146149
model_output: dict[str, Any],
147150
judge_client: JudgeClientProtocol,
151+
dimensions: tuple[str, ...] | None = None,
148152
) -> dict[str, Any]:
149153
"""Score *model_output* against *task*'s rubric using *judge_client*.
150154
@@ -168,21 +172,29 @@ def score_rubric(
168172
model_output: The normalized model output dict. Passed through to the
169173
judge client unchanged.
170174
judge_client: An object implementing :class:`JudgeClientProtocol`. The
171-
scorer makes exactly one ``judge`` call per dimension (six calls
172-
total) with no retries; cap and determinism settings (temperature 0,
173-
fixed seed) should be configured on the client itself — see
175+
scorer makes exactly one ``judge`` call per scored dimension; cap
176+
and determinism settings (temperature 0, fixed seed) should be
177+
configured on the client itself — see
174178
:mod:`benchmark.rubrics.judge_client`.
179+
dimensions: Optional subset of :data:`RUBRIC_DIMENSIONS` to judge.
180+
When ``None`` (default) all six dimensions are judged. The
181+
dispatcher passes only the C2-owned dimensions
182+
(``insight_quality``, ``evidence_linkage``,
183+
``structure_usability``) since the other three are owned by
184+
C1/C3/C4 — judging them would be wasted API spend. Weight
185+
validation always runs over the full rubric regardless.
175186
176187
Returns:
177188
A dict with the following keys:
178189
179190
- ``"task_id"`` (str): Copied from *task*.
180191
- ``"dimension_scores"`` (dict[str, float]): Per-dimension sub-scores
181-
in [0, 1] keyed by dimension name.
192+
in [0, 1] keyed by dimension name (scored dimensions only).
182193
- ``"rubric_weights"`` (dict[str, float]): The per-dimension weights
183-
extracted from the task rubric.
194+
extracted from the task rubric (scored dimensions only).
184195
- ``"composite"`` (float): The weighted aggregate score in [0, 1],
185-
computed as ``sum(weight_i * score_i)`` over all six dimensions.
196+
computed as ``sum(weight_i * score_i)`` over the scored dimensions
197+
(partial when a ``dimensions`` subset is requested).
186198
187199
Raises:
188200
ValueError: If the task rubric weights do not sum to
@@ -202,14 +214,20 @@ def score_rubric(
202214
rubric: dict[str, Any] = task["rubric"]
203215
task_id: str = task["task_id"]
204216

205-
# Validate weight sum before making any judge calls.
217+
# Validate weight sum before making any judge calls. Always validates
218+
# the full rubric, even when only a subset of dimensions is judged.
206219
validate_rubric_weights(rubric)
207220

221+
scored_dimensions: tuple[str, ...] = dimensions if dimensions is not None else RUBRIC_DIMENSIONS
222+
unknown = [d for d in scored_dimensions if d not in RUBRIC_DIMENSIONS]
223+
if unknown:
224+
raise ValueError(f"Unknown rubric dimension(s) requested: {unknown}")
225+
208226
dimension_scores: dict[str, float] = {}
209227
rubric_weights: dict[str, float] = {}
210228
composite: float = 0.0
211229

212-
for dim in RUBRIC_DIMENSIONS:
230+
for dim in scored_dimensions:
213231
dim_spec: dict[str, Any] = rubric[dim]
214232
weight: float = float(dim_spec["weight"])
215233
guidance: str = dim_spec.get("guidance", "")

runner/dispatcher.py

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,10 @@ def judge(
214214
def _score_c1_grounding(task: dict[str, Any], output: dict[str, Any]) -> float:
215215
"""Run C1 fact scoring and return ``grounding_accuracy`` in [0, 1].
216216
217+
The output's ``limitations`` list is passed through as a documented
218+
fallback search target — models often state caveated numbers ("only 127
219+
of the 135 enrolled students attended") in their limitations section.
220+
217221
Args:
218222
task: Task definition dict.
219223
output: Normalized model output dict.
@@ -225,22 +229,32 @@ def _score_c1_grounding(task: dict[str, Any], output: dict[str, Any]) -> float:
225229
gold_facts=task.get("gold_facts", []),
226230
structured_metrics=output.get("structured_metrics", {}),
227231
key_findings=output.get("key_findings", []),
232+
limitations=output.get("limitations", []),
228233
)
229234
return result.grounding_accuracy
230235

231236

237+
#: The rubric dimensions whose authoritative score comes from the C2 judge.
238+
#: The other three (grounding_accuracy, calibration_limitation_handling,
239+
#: consistency) are owned by C1/C3/C4, so judging them would be wasted API
240+
#: spend — at 26 tasks × 5 runs that's 390 discarded judge calls per model.
241+
_C2_OWNED_DIMENSIONS: tuple[str, ...] = (
242+
"insight_quality",
243+
"evidence_linkage",
244+
"structure_usability",
245+
)
246+
247+
232248
def _score_c2_rubric(
233249
task: dict[str, Any],
234250
output: dict[str, Any],
235251
judge_client: Any,
236252
) -> dict[str, float]:
237253
"""Run C2 rubric scoring and return per-dimension scores.
238254
239-
Only the C2-owned dimensions are meaningful here:
240-
``insight_quality``, ``evidence_linkage``, and ``structure_usability``.
241-
The ``calibration_limitation_handling`` and ``consistency`` values from
242-
C2 are overridden by C3 and C4 respectively; ``grounding_accuracy`` is
243-
overridden by C1.
255+
Only the C2-owned dimensions are judged: ``insight_quality``,
256+
``evidence_linkage``, and ``structure_usability``. The remaining three
257+
dimensions are owned by C1/C3/C4 and are never sent to the judge.
244258
245259
Args:
246260
task: Task definition dict.
@@ -249,15 +263,15 @@ def _score_c2_rubric(
249263
:class:`~benchmark.rubrics.rubric_scoring.JudgeClientProtocol`.
250264
251265
Returns:
252-
Dict mapping each of the six dimension names to a float in [0, 1].
253-
On error (e.g. malformed rubric), returns zero scores for all
254-
dimensions.
266+
Dict mapping each C2-owned dimension name to a float in [0, 1].
267+
On error (e.g. malformed rubric), returns zero scores for the
268+
C2-owned dimensions.
255269
"""
256270
try:
257-
result = score_rubric(task, output, judge_client)
271+
result = score_rubric(task, output, judge_client, dimensions=_C2_OWNED_DIMENSIONS)
258272
return dict(result["dimension_scores"])
259273
except (ValueError, KeyError):
260-
return dict.fromkeys(RUBRIC_DIMENSIONS, 0.0)
274+
return dict.fromkeys(_C2_OWNED_DIMENSIONS, 0.0)
261275

262276

263277
def _score_c3_calibration(
@@ -407,7 +421,7 @@ def run_task(
407421
# --- C1: grounding accuracy via fact scoring ---
408422
c1_score = _score_c1_grounding(task, output)
409423

410-
# --- C2: rubric scoring (all six dimensions) ---
424+
# --- C2: rubric scoring (judged dimensions only) ---
411425
c2_scores = _score_c2_rubric(task, output, effective_judge)
412426

413427
# --- C3: claim validation → calibration_limitation_handling ---

tests/unit/test_rubric_scoring.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,29 @@ def test_judge_called_once_per_dimension(self) -> None:
252252
score_rubric(_VALID_TASK, _VALID_OUTPUT, mock)
253253
assert mock.judge.call_count == len(RUBRIC_DIMENSIONS)
254254

255+
def test_dimensions_subset_judges_only_those(self) -> None:
256+
"""A dimensions subset must restrict judge calls and returned scores.
257+
258+
The dispatcher passes only the C2-owned dimensions so judge calls for
259+
C1/C3/C4-owned dimensions are never made (methodology finding M-2).
260+
"""
261+
subset = ("insight_quality", "evidence_linkage", "structure_usability")
262+
mock = _make_mock_judge(0.5)
263+
result = score_rubric(_VALID_TASK, _VALID_OUTPUT, mock, dimensions=subset)
264+
265+
assert mock.judge.call_count == len(subset)
266+
called_dims = {call.kwargs["dimension"] for call in mock.judge.call_args_list}
267+
assert called_dims == set(subset)
268+
assert set(result["dimension_scores"].keys()) == set(subset)
269+
assert set(result["rubric_weights"].keys()) == set(subset)
270+
271+
def test_dimensions_subset_unknown_dimension_raises(self) -> None:
272+
"""An unknown dimension in the subset must raise before judge calls."""
273+
mock = _make_mock_judge(0.5)
274+
with pytest.raises(ValueError, match="Unknown rubric dimension"):
275+
score_rubric(_VALID_TASK, _VALID_OUTPUT, mock, dimensions=("not_a_dim",))
276+
assert mock.judge.call_count == 0
277+
255278
def test_judge_called_with_correct_dimension_names(self) -> None:
256279
"""Each judge call must receive the correct dimension name as the first arg."""
257280
mock = _make_mock_judge(0.5)

tests/unit/test_runner_dispatcher.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,43 @@ def test_consistency_nonzero_for_multiple_runs(self) -> None:
283283
# Stub always emits the same first key_finding → consistency close to 1.0.
284284
assert result.scores.get("consistency", 0.0) > 0.0
285285

286+
def test_c1_grounding_searches_limitations(self) -> None:
287+
"""A gold-fact value stated only in limitations must be credited (M-1)."""
288+
from runner.dispatcher import _score_c1_grounding
289+
290+
task = {
291+
"task_id": "T-LIM",
292+
"gold_facts": [
293+
{
294+
"fact_id": "F1",
295+
"claim": "The program enrolls 977 students.",
296+
"source_files": ["students.csv"],
297+
"numeric_value": 977,
298+
"tolerance": 0,
299+
}
300+
],
301+
}
302+
output = {
303+
"structured_metrics": {},
304+
"key_findings": ["Enrollment is healthy this quarter."],
305+
"limitations": ["Note that only 977 students are reflected in this snapshot."],
306+
}
307+
assert _score_c1_grounding(task, output) == pytest.approx(1.0)
308+
309+
def test_c2_judge_not_called_for_non_c2_dimensions(self) -> None:
310+
"""The judge must only be called for C2-owned dimensions (M-2)."""
311+
adapter = StubAdapter()
312+
mock_judge = MagicMock()
313+
mock_judge.judge.return_value = 0.5
314+
run_task(_VALID_TASK, adapter, runs=1, judge_client=mock_judge)
315+
316+
called_dims = {call.kwargs["dimension"] for call in mock_judge.judge.call_args_list}
317+
assert "grounding_accuracy" not in called_dims
318+
assert "consistency" not in called_dims
319+
# C3 may route calibration through the judge as a Stage-2 paraphrase
320+
# fallback, but C2 itself must cover exactly the three owned dims.
321+
assert {"insight_quality", "evidence_linkage", "structure_usability"} <= called_dims
322+
286323

287324
# ---------------------------------------------------------------------------
288325
# C3 integration tests — calibration_limitation_handling driven by validate_claims

0 commit comments

Comments
 (0)