| """Unit tests for the stratified subset sampler used by the eval harness. |
| |
| The sampler lets us run a smaller, representative slice of the eval set when the |
| LLM free-tier budget can't absorb the full 40 questions in one run. It must be |
| deterministic (reproducible runs) and preserve the proportion of each stratum. |
| """ |
| from collections import Counter |
|
|
| from scripts.eval import ( |
| _build_question_result, |
| _judge_mode_label, |
| _load_questions, |
| _print_report, |
| rejudge_results, |
| select_by_ids, |
| stratified_subset, |
| ) |
|
|
|
|
| def _q(id_, difficulty): |
| return {"id": id_, "difficulty": difficulty, "question": f"q{id_}"} |
|
|
|
|
| def _dataset(): |
| |
| rows = ( |
| [("a", "a")] * 5 + [("b", "b")] * 3 + [("c", "c")] * 2 |
| ) |
| return [_q(f"eval-{i:03d}", diff) for i, (_, diff) in enumerate(rows)] |
|
|
|
|
| def test_limit_none_returns_all(): |
| qs = _dataset() |
| assert stratified_subset(qs, None) == qs |
|
|
|
|
| def test_limit_ge_total_returns_all(): |
| qs = _dataset() |
| assert stratified_subset(qs, 99) == qs |
|
|
|
|
| def test_limit_zero_returns_empty(): |
| assert stratified_subset(_dataset(), 0) == [] |
|
|
|
|
| def test_returns_exactly_limit(): |
| qs = _dataset() |
| assert len(stratified_subset(qs, 5)) == 5 |
| assert len(stratified_subset(qs, 7)) == 7 |
|
|
|
|
| def test_preserves_strata_proportions_largest_remainder(): |
| |
| |
| |
| qs = _dataset() |
| counts = Counter(q["difficulty"] for q in stratified_subset(qs, 5)) |
| assert counts == {"a": 3, "b": 1, "c": 1} |
|
|
|
|
| def test_is_deterministic(): |
| qs = _dataset() |
| a = [q["id"] for q in stratified_subset(qs, 6, seed=42)] |
| b = [q["id"] for q in stratified_subset(qs, 6, seed=42)] |
| assert a == b |
|
|
|
|
| def test_preserves_original_order(): |
| qs = _dataset() |
| out = stratified_subset(qs, 6) |
| ids = [q["id"] for q in out] |
| assert ids == sorted(ids) |
|
|
|
|
| def test_every_stratum_represented_when_room(): |
| qs = _dataset() |
| counts = Counter(q["difficulty"] for q in stratified_subset(qs, 6)) |
| assert set(counts) == {"a", "b", "c"} |
|
|
|
|
| |
| |
| |
|
|
| def test_select_by_ids_filters_and_preserves_order(): |
| qs = _dataset() |
| out = select_by_ids(qs, ["eval-005", "eval-001", "eval-008"]) |
| |
| assert [q["id"] for q in out] == ["eval-001", "eval-005", "eval-008"] |
|
|
|
|
| def test_select_by_ids_ignores_unknown_ids(): |
| qs = _dataset() |
| out = select_by_ids(qs, ["eval-099", "eval-003", "nope"]) |
| assert [q["id"] for q in out] == ["eval-003"] |
|
|
|
|
| def test_select_by_ids_empty_returns_empty(): |
| assert select_by_ids(_dataset(), []) == [] |
|
|
|
|
| |
| |
| |
|
|
| def test_build_question_result_persists_full_answer_and_canonical(): |
| |
| |
| |
| q = { |
| "id": "eval-001", "question": "Q?", "difficulty": "hard", "source": "ruling", |
| "rule_reference": "103.2", "canonical_answer": "The canonical answer.", |
| } |
| long_answer = "A" * 500 |
| pipeline_result = {"answer": long_answer, "confidence": 0.7, "latency_ms": 1234} |
| judgment = {"verdict": "correct", "justification": "ok"} |
|
|
| r = _build_question_result( |
| q, 1, pipeline_result, has_ref=True, retrieval_hit=True, judgment=judgment, |
| ) |
|
|
| assert r["answer"] == long_answer |
| assert r["canonical_answer"] == "The canonical answer." |
| |
| assert r["answer_preview"] == long_answer[:300] |
| assert len(r["answer_preview"]) == 300 |
| |
| assert r["id"] == "eval-001" |
| assert r["verdict"] == "correct" |
| assert r["has_ref"] is True |
| assert r["retrieval_hit"] is True |
| assert r["confidence"] == 0.7 |
|
|
|
|
| def test_build_question_result_defaults_missing_fields(): |
| q = {"question": "Q?"} |
| pipeline_result = {"answer": "", "confidence": 0.0, "latency_ms": 0} |
| judgment = {"verdict": "error", "justification": "boom"} |
|
|
| r = _build_question_result( |
| q, 3, pipeline_result, has_ref=False, retrieval_hit=False, judgment=judgment, |
| ) |
|
|
| assert r["id"] == "q3" |
| assert r["canonical_answer"] == "" |
| assert r["difficulty"] == "unknown" |
| assert r["source"] == "unknown" |
| assert r["answer"] == "" |
|
|
|
|
| |
| |
| |
|
|
| def test_rejudge_results_rescores_with_injected_judge(): |
| saved = [{ |
| "id": "eval-001", "question": "Q1", "canonical_answer": "C1", "answer": "A1", |
| "difficulty": "hard", "source": "ruling", "has_ref": True, "retrieval_hit": True, |
| "verdict": "wrong", "justification": "old gemma verdict", |
| "confidence": 0.6, "latency_ms": 100, |
| }] |
| fake_judge = lambda q, c, a: {"verdict": "correct", "justification": "strong judge says ok"} |
|
|
| out = rejudge_results(saved, judge=fake_judge) |
|
|
| |
| assert out[0]["verdict"] == "correct" |
| assert out[0]["justification"] == "strong judge says ok" |
| |
| assert out[0]["retrieval_hit"] is True |
| assert out[0]["has_ref"] is True |
| assert out[0]["answer"] == "A1" |
| assert out[0]["confidence"] == 0.6 |
|
|
|
|
| def test_rejudge_results_passes_saved_answer_and_canonical_to_judge(): |
| saved = [{"question": "Q", "canonical_answer": "CANON", "answer": "GENERATED"}] |
| seen = {} |
| def spy_judge(q, c, a): |
| seen.update(question=q, canonical=c, answer=a) |
| return {"verdict": "partial", "justification": "j"} |
|
|
| rejudge_results(saved, judge=spy_judge) |
|
|
| assert seen == {"question": "Q", "canonical": "CANON", "answer": "GENERATED"} |
|
|
|
|
| def test_rejudge_results_marks_error_when_answer_missing(): |
| |
| saved = [{"id": "x", "question": "Q", "canonical_answer": "C", "verdict": "wrong"}] |
| called = [] |
| fake_judge = lambda q, c, a: called.append(1) or {"verdict": "correct", "justification": "x"} |
|
|
| out = rejudge_results(saved, judge=fake_judge) |
|
|
| assert out[0]["verdict"] == "error" |
| assert "re-judge" in out[0]["justification"].lower() |
| assert called == [] |
|
|
|
|
| |
| |
| |
|
|
| def test_print_report_empty_results_no_crash(capsys): |
| |
| |
| |
| _print_report([]) |
| out = capsys.readouterr().out |
| assert "Total questions : 0" in out |
|
|
|
|
| |
| |
| |
|
|
| def _clear_judge_env(monkeypatch): |
| for var in ( |
| "JUDGE_PROVIDER", "JUDGE_BASE_URL", "JUDGE_API_KEY", "JUDGE_MODEL", |
| "LLM_BASE_URL", "LLM_API_KEY", "LLM_MODEL", "GEMINI_API_KEY", |
| ): |
| monkeypatch.delenv(var, raising=False) |
|
|
|
|
| def test_judge_mode_label_base_url_without_creds_is_not_openai_compat(monkeypatch): |
| |
| |
| |
| _clear_judge_env(monkeypatch) |
| monkeypatch.setenv("JUDGE_BASE_URL", "http://localhost:1234/v1") |
| monkeypatch.setenv("GEMINI_API_KEY", "k") |
| assert _judge_mode_label() == "gemini (GEMINI_API_KEY)" |
|
|
|
|
| def test_judge_mode_label_full_judge_vars_is_openai_compat(monkeypatch): |
| _clear_judge_env(monkeypatch) |
| monkeypatch.setenv("JUDGE_BASE_URL", "http://x") |
| monkeypatch.setenv("JUDGE_API_KEY", "k") |
| monkeypatch.setenv("JUDGE_MODEL", "m") |
| assert _judge_mode_label() == "openai_compat (JUDGE_*)" |
|
|
|
|
| def test_judge_mode_label_llm_fallback_warns_shared_quota(monkeypatch): |
| _clear_judge_env(monkeypatch) |
| monkeypatch.setenv("LLM_BASE_URL", "http://x") |
| monkeypatch.setenv("LLM_API_KEY", "k") |
| monkeypatch.setenv("LLM_MODEL", "m") |
| assert "shares quota" in _judge_mode_label() |
|
|
|
|
| def test_judge_mode_label_forced_gemini(monkeypatch): |
| _clear_judge_env(monkeypatch) |
| monkeypatch.setenv("LLM_BASE_URL", "http://x") |
| monkeypatch.setenv("LLM_API_KEY", "k") |
| monkeypatch.setenv("LLM_MODEL", "m") |
| monkeypatch.setenv("JUDGE_PROVIDER", "gemini") |
| assert "forced" in _judge_mode_label() |
|
|
|
|
| |
| |
| |
|
|
| def test_load_questions_unwraps_dict(tmp_path): |
| import json |
| p = tmp_path / "wrapped.json" |
| p.write_text(json.dumps({"questions": [{"id": "a"}]}), encoding="utf-8") |
| assert _load_questions(p) == [{"id": "a"}] |
|
|
|
|
| def test_load_questions_accepts_bare_list(tmp_path): |
| import json |
| p = tmp_path / "bare.json" |
| p.write_text(json.dumps([{"id": "b"}]), encoding="utf-8") |
| assert _load_questions(p) == [{"id": "b"}] |
|
|