Skip to content

Commit fefbf80

Browse files
Fix completeness gate: distinguish inapplicable metrics from real failures (#78)
1 parent 8600458 commit fefbf80

4 files changed

Lines changed: 94 additions & 10 deletions

File tree

src/metrics/base.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@
1818
from .phase import Phase, PhaseInput, PhaseOutput
1919

2020

21+
class MetricNotApplicableError(Exception):
22+
"""Raised when a metric legitimately does not apply to a question.
23+
24+
Distinct from a failure: the cell was never scorable (e.g. distractor
25+
quality on a true/false item), so completeness accounting must not treat
26+
it as data loss. Every other exception is a real loss.
27+
"""
28+
29+
2130
class MetricScope(str, Enum):
2231
"""Defines the scope at which a metric operates."""
2332

src/metrics/distractor.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from pydantic import BaseModel, ConfigDict, Field
66

77
from ..models.quiz import QuestionType, QuizQuestion
8-
from .base import BaseMetric, MetricScope
8+
from .base import BaseMetric, MetricNotApplicableError, MetricScope
99
from .phase import Phase, PhaseInput
1010

1111
QUALITY_SCORES = {
@@ -102,7 +102,7 @@ def _build_analyze_prompt(inp: PhaseInput) -> str:
102102
question = inp.question
103103

104104
if question.question_type not in (QuestionType.SINGLE_CHOICE, QuestionType.MULTIPLE_CHOICE):
105-
raise ValueError(
105+
raise MetricNotApplicableError(
106106
f"Distractor quality cannot be evaluated for {question.question_type.value} questions. "
107107
"Only single_choice and multiple_choice are supported."
108108
)

src/runners/benchmark.py

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from ..evaluators.base import LLMProvider, TransientLLMError
99
from ..evaluators.factory import LLMProviderFactory
1010
from ..evaluators.ollama import OllamaProvider
11-
from ..metrics.base import BaseMetric, MetricScope
11+
from ..metrics.base import BaseMetric, MetricNotApplicableError, MetricScope
1212
from ..metrics.registry import MetricRegistry
1313
from ..models.config import BenchmarkConfig
1414
from ..models.instruction import QuizInstructions
@@ -191,11 +191,26 @@ def _evaluate_quiz_level(
191191
}
192192
)
193193
return None
194+
except MetricNotApplicableError as e:
195+
self.logger.info(
196+
"Metric %s not applicable to quiz %s: %s", metric.name, quiz.quiz_id, e
197+
)
198+
self._cell_failures.append(
199+
{
200+
"category": "skipped",
201+
"metric": metric.name,
202+
"evaluator": evaluator.model_name,
203+
"quiz_id": quiz.quiz_id,
204+
"question_id": None,
205+
"error": str(e),
206+
}
207+
)
208+
return None
194209
except Exception as e: # noqa: BLE001
195210
self.logger.error("Error evaluating quiz %s: %s", quiz.quiz_id, e)
196211
self._cell_failures.append(
197212
{
198-
"category": "skipped",
213+
"category": "failed",
199214
"metric": metric.name,
200215
"evaluator": evaluator.model_name,
201216
"quiz_id": quiz.quiz_id,
@@ -289,11 +304,26 @@ def _evaluate_question(
289304
}
290305
)
291306
return None
307+
except MetricNotApplicableError as e:
308+
self.logger.info(
309+
"Metric %s not applicable to question %s: %s", metric.name, question.question_id, e
310+
)
311+
self._cell_failures.append(
312+
{
313+
"category": "skipped",
314+
"metric": metric.name,
315+
"evaluator": evaluator.model_name,
316+
"quiz_id": quiz.quiz_id,
317+
"question_id": question.question_id,
318+
"error": str(e),
319+
}
320+
)
321+
return None
292322
except Exception as e: # noqa: BLE001
293323
self.logger.error("Error evaluating question %s: %s", question.question_id, e)
294324
self._cell_failures.append(
295325
{
296-
"category": "skipped",
326+
"category": "failed",
297327
"metric": metric.name,
298328
"evaluator": evaluator.model_name,
299329
"quiz_id": quiz.quiz_id,
@@ -304,7 +334,7 @@ def _evaluate_question(
304334
return None
305335

306336
def get_completeness_report(self) -> dict:
307-
failed = [c for c in self._cell_failures if c["category"] == "transient"]
337+
failed = [c for c in self._cell_failures if c["category"] in ("transient", "failed")]
308338
skipped = [c for c in self._cell_failures if c["category"] == "skipped"]
309339
present = self._attempted - len(self._cell_failures)
310340
return {

tests/test_runner.py

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import pytest
77

8+
from src.metrics.base import MetricNotApplicableError
89
from src.models.config import MetricConfig
910
from src.runners.benchmark import BenchmarkRunner
1011

@@ -353,8 +354,7 @@ def test_evaluator_init_failure_raises(monkeypatch, sample_config):
353354
"""A declared evaluator that cannot be created must abort the run, not be skipped.
354355
355356
Silently dropping one leaves a sweep with fewer judges than planned, visible only in
356-
metadata.json after the calls have been spent -- and two of the four models reported in
357-
the paper are locally served.
357+
metadata.json after the calls have been spent.
358358
"""
359359
from src.evaluators.factory import LLMProviderFactory
360360
from src.runners.benchmark import BenchmarkRunner
@@ -519,7 +519,7 @@ def fail_first(*args, **kwargs):
519519
def test_skipped_error_does_not_fail_completeness(
520520
registered_metrics, mock_llm_provider, sample_config, sample_quiz
521521
):
522-
"""A ValueError (e.g. distractor on true/false) is a skip, not a failure."""
522+
"""MetricNotApplicableError (distractor on true/false) is a skip, not a failure."""
523523
from dataclasses import replace
524524
from unittest.mock import patch
525525

@@ -544,7 +544,7 @@ def test_skipped_error_does_not_fail_completeness(
544544
def skip_first(*args, **kwargs):
545545
call_count[0] += 1
546546
if call_count[0] == 1:
547-
raise ValueError("Not applicable for true/false")
547+
raise MetricNotApplicableError("Not applicable for true/false")
548548
return original_evaluate(*args, **kwargs)
549549

550550
with patch.object(runner.metrics["clarity"], "evaluate", side_effect=skip_first):
@@ -554,3 +554,48 @@ def skip_first(*args, **kwargs):
554554
assert report["complete"]
555555
assert report["skipped"] == 1
556556
assert len(report["skipped_cells"]) == 1
557+
558+
559+
def test_truncation_error_fails_completeness(
560+
registered_metrics, mock_llm_provider, sample_config, sample_quiz
561+
):
562+
"""max_tokens truncation is data loss, not a skip: it must flip `complete`.
563+
564+
Regression guard: 36 cells were lost to 'length limit was reached' truncation
565+
yet the run reported complete and exited 0.
566+
"""
567+
from dataclasses import replace
568+
from unittest.mock import patch
569+
570+
config = replace(
571+
sample_config,
572+
runs=1,
573+
metrics=[
574+
MetricConfig(
575+
name="clarity",
576+
version="2.0",
577+
evaluators=["mock_eval"],
578+
parameters={},
579+
enabled=True,
580+
)
581+
],
582+
)
583+
runner = BenchmarkRunner(config)
584+
585+
original_evaluate = runner.metrics["clarity"].evaluate
586+
call_count = [0]
587+
588+
def truncate_first(*args, **kwargs):
589+
call_count[0] += 1
590+
if call_count[0] == 1:
591+
raise ValueError("Could not parse response content as the length limit was reached")
592+
return original_evaluate(*args, **kwargs)
593+
594+
with patch.object(runner.metrics["clarity"], "evaluate", side_effect=truncate_first):
595+
runner.run(quizzes=[sample_quiz], source_texts={"quiz_1": "text"})
596+
597+
report = runner.get_completeness_report()
598+
assert not report["complete"], "truncation must not be absorbed as a skip"
599+
assert report["failed"] == 1
600+
assert report["skipped"] == 0
601+
assert report["failed_cells"][0]["category"] == "failed"

0 commit comments

Comments
 (0)