Files
ely-eye/bench/tests/test_metrics.py
T
ZacharyZhang-NY fc0698c242 Add baseline harness: ingest, metrics, ablation arms, external counterpart, audit
- bench/ingest_baseline.py drives the unmodified product IngestionService over
  all 50,789 corpus files in sharded SQLite DBs, then merges them. Thread and
  memory ceilings are set explicitly: ONNX Runtime defaulted to every core
  (95 threads/worker, load 490) and its CPU arena kept freed pages mapped,
  which OOM-killed workers.
- bench/metrics.py: recall@k, MRR, nDCG@10, Wilson lower bound, chance
  baseline. No model, no product import, so the scorer cannot grade itself.
- bench/systems.py + bench/run.py: the five ablation arms over the merged DB,
  latency percentiles, scale curve, and a top-10 equivalence check against the
  live RetrievalService.
- bench/counterpart.py + bench/run_external.py: in-repo Mem0-style flat store
  and Zep-style temporal store as the external reference point.
- bench/audit.py: enforces single-attempt scoring, frozen n, a chance row
  beside every score, and producer != verifier. It already flags the product's
  retry-until-target loop at backend/src/ely_eye/evals.py:2351.
- backend/pyproject.toml: rapidocr-onnxruntime>=1.4.4 was unsatisfiable
  (PyPI maxes at 1.2.3); the successor is the renamed rapidocr package.
2026-10-06 14:36:02 -04:00

104 lines
3.8 KiB
Python

"""Hand-computed checks for every metric, so a wrong number cannot hide behind a green run."""
from __future__ import annotations
import pytest
from bench.metrics import (
aggregate,
chance_recall_at_k,
first_hit_rank,
ndcg_at_10,
recall_at_k,
reciprocal_rank,
score_query,
wilson_lower_bound,
)
def test_recall_at_k_counts_distinct_gold_in_window() -> None:
assert recall_at_k(["a", "b", "c", "d"], {"c"}, 1) == 0.0
assert recall_at_k(["a", "b", "c", "d"], {"c"}, 3) == 1.0
assert recall_at_k(["c", "x", "a"], {"a", "c"}, 1) == 0.5
assert recall_at_k(["c", "x", "a"], {"a", "c"}, 3) == 1.0
assert recall_at_k(["a", "a", "b"], {"a", "b"}, 3) == 1.0 # duplicates count once
def test_recall_is_zero_when_gold_is_empty() -> None:
assert recall_at_k(["a"], set(), 3) == 0.0
def test_reciprocal_rank_uses_first_hit_only() -> None:
assert reciprocal_rank(["x", "a", "y", "b"], {"b"}) == 0.25
assert reciprocal_rank(["x", "a", "y", "b"], {"a", "b"}) == 0.5
assert reciprocal_rank(["x", "y"], {"z"}) == 0.0
assert first_hit_rank(["x", "a", "y", "b"], {"b"}) == 4
assert first_hit_rank(["x", "y"], {"z"}) is None
def test_ndcg_matches_hand_computation() -> None:
# DCG = 1/log2(2) + 1/log2(4) = 1.5; IDCG = 1/log2(2) + 1/log2(3) = 1.6309297535714574
assert ndcg_at_10(["a", "x", "b"], {"a", "b"}) == pytest.approx(1.5 / 1.6309297535714574)
assert ndcg_at_10(["a", "b"], {"a", "b"}) == pytest.approx(1.0)
assert ndcg_at_10(["x", "y"], {"a"}) == 0.0
def test_ndcg_ignores_results_past_ten() -> None:
ranked = ["x"] * 10 + ["a"]
assert ndcg_at_10(ranked, {"a"}) == 0.0
def test_wilson_lower_bound_matches_published_interval() -> None:
# 90/100 at 95% two-sided is [0.8257, 0.9456] in standard tables.
assert wilson_lower_bound(90, 100) == pytest.approx(0.8257, abs=1e-3)
assert wilson_lower_bound(0, 10) == 0.0
assert wilson_lower_bound(10, 10) == pytest.approx(0.7225, abs=1e-3)
assert wilson_lower_bound(1, 10) < 0.5
def test_wilson_rejects_impossible_counts() -> None:
with pytest.raises(SystemExit):
wilson_lower_bound(5, 0)
with pytest.raises(SystemExit):
wilson_lower_bound(11, 10)
def test_chance_recall_is_uniform_baseline() -> None:
assert chance_recall_at_k(1, 1000, 10) == pytest.approx(0.01)
assert chance_recall_at_k(4, 100, 10) == pytest.approx(0.1)
assert chance_recall_at_k(0, 100, 10) == 0.0
with pytest.raises(SystemExit):
chance_recall_at_k(1, 0, 10)
def test_score_query_records_holder_and_abstention() -> None:
holder_of = {"a": "alice", "b": "bob"}
scored = score_query("q1", ["b"], {"b"}, holder="alice", holder_of=holder_of)
assert scored.gold == 1
assert scored.recall_at_1 == 1.0
assert scored.abstained is False
assert scored.top_hit_from_holder is False
assert score_query("q2", [], {"b"}, holder="alice", holder_of=holder_of).abstained is True
def test_aggregate_separates_answerable_from_unanswerable() -> None:
scores = [
score_query("hit", ["a", "x", "y"], {"a"}, holder="alice"),
score_query("miss", ["x", "y", "a"], {"a"}, holder="alice"),
score_query("none", [], set(), holder="alice"),
]
summary = aggregate(scores)
assert summary["n_answerable"] == 2
assert summary["n_unanswerable"] == 1
assert summary["recall_at_1"] == pytest.approx(0.5)
assert summary["recall_at_10"] == pytest.approx(1.0)
assert summary["mrr"] == pytest.approx((1.0 + 1 / 3) / 2)
assert summary["hit_at_10"] == 1.0
assert summary["abstention_rate_answerable"] == 0.0
assert summary["abstention_rate_unanswerable"] == 1.0
def test_aggregate_needs_answerable_queries() -> None:
with pytest.raises(SystemExit):
aggregate([score_query("none", [], set())])