Files
wehub-resource-sync 6ede33ccdb
Build and Push Docker Images / create_manifest (web, surfsense-web, , cpu) (push) Has been cancelled
Build and Push Docker Images / finalize_release (push) Has been cancelled
Obsidian Plugin Lint / lint (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_web, cpu, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64, , runner, false, cpu) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_web, cpu, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64, , runner, false, cpu) (push) Has been cancelled
Build and Push Docker Images / compute_version (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cpu, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, , production, false, cpu) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cpu, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, , production, false, cpu) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cu126, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, -cuda126, production, true, cuda126) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cu126, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, -cuda126, production, true, cuda126) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cu128, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, -cuda, production, true, cuda) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cu128, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, -cuda, production, true, cuda) (push) Has been cancelled
Build and Push Docker Images / verify_digests (push) Has been cancelled
Build and Push Docker Images / create_manifest (backend, surfsense-backend, , cpu) (push) Has been cancelled
Build and Push Docker Images / create_manifest (backend, surfsense-backend, -cuda, cuda) (push) Has been cancelled
Build and Push Docker Images / create_manifest (backend, surfsense-backend, -cuda126, cuda126) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:33:44 +08:00

154 lines
4.8 KiB
Python

"""Metric correctness — Wilson, McNemar, retrieval scores."""
from __future__ import annotations
import math
import pytest
from surfsense_evals.core.metrics import (
accuracy_with_wilson_ci,
bootstrap_delta_ci,
mcnemar_test,
mrr,
ndcg_at_k,
recall_at_k,
score_run,
wilson_ci,
)
# ---------------------------------------------------------------------------
# Wilson
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"k,n,low,high",
[
(80, 100, 0.7111, 0.8666), # cross-checked vs statsmodels.proportion_confint(method='wilson')
(50, 100, 0.4038, 0.5962),
(0, 0, 0.0, 1.0),
(0, 10, 0.0, 0.2775),
(10, 10, 0.7225, 1.0),
],
)
def test_wilson_ci_known_values(k, n, low, high):
result_low, result_high = wilson_ci(k, n)
assert math.isclose(result_low, low, abs_tol=5e-4), (k, n, result_low, low)
assert math.isclose(result_high, high, abs_tol=5e-4), (k, n, result_high, high)
def test_accuracy_with_wilson_ci_object():
res = accuracy_with_wilson_ci(70, 100)
assert res.accuracy == 0.7
assert 0.0 < res.ci_low < res.ci_high < 1.0
def test_invalid_inputs_raise():
with pytest.raises(ValueError):
accuracy_with_wilson_ci(-1, 10)
with pytest.raises(ValueError):
accuracy_with_wilson_ci(11, 10)
# ---------------------------------------------------------------------------
# McNemar
# ---------------------------------------------------------------------------
def test_mcnemar_degenerate_returns_p_value_one():
a = [True, True, False, False]
b = [True, True, False, False]
res = mcnemar_test(a, b)
assert res.b == 0 and res.c == 0
assert res.p_value == 1.0
assert res.method == "degenerate"
def test_mcnemar_exact_branch_strong_signal():
"""B = 0, C = 10 → exact two-sided binomial p == 2 * (1/2)**10."""
a = [True] * 10 + [False] * 10
b = [True] * 10 + [True] * 10 # surfsense beats native on the 10 native-wrong
res = mcnemar_test(a, b)
assert res.b == 0
assert res.c == 10
assert res.method == "exact"
expected = 2 * (0.5 ** 10)
assert math.isclose(res.p_value, expected, rel_tol=1e-9)
def test_mcnemar_chi_square_approx_for_large_discordant():
# Construct b=15, c=5 with continuity-corrected chi^2 = (|10|-1)^2/20 = 4.05.
a = [True] * 15 + [False] * 5 + [True] * 30 + [False] * 30
b = [False] * 15 + [True] * 5 + [True] * 30 + [False] * 30
res = mcnemar_test(a, b)
assert res.method == "chi2_cc"
assert res.b == 15 and res.c == 5
assert math.isclose(res.statistic, ((abs(15 - 5) - 1) ** 2) / 20.0, rel_tol=1e-9)
# p ≈ chi2.sf(4.05, df=1) ≈ 0.04417
assert 0.04 < res.p_value < 0.05
def test_mcnemar_length_mismatch():
with pytest.raises(ValueError):
mcnemar_test([True], [True, False])
# ---------------------------------------------------------------------------
# Bootstrap
# ---------------------------------------------------------------------------
def test_bootstrap_delta_ci_shape_and_determinism():
a = [True, True, False, True, False, False, True, True]
b = [True, True, True, True, True, False, True, False]
res1 = bootstrap_delta_ci(a, b, n_resamples=500, random_state=42)
res2 = bootstrap_delta_ci(a, b, n_resamples=500, random_state=42)
assert res1.delta == res2.delta
assert res1.ci_low == res2.ci_low
assert res1.ci_high == res2.ci_high
assert res1.ci_low <= res1.delta <= res1.ci_high
assert res1.n_resamples == 500
# ---------------------------------------------------------------------------
# Retrieval
# ---------------------------------------------------------------------------
def test_recall_at_k():
retrieved = ["a", "b", "c", "d"]
relevant = ["b", "d", "z"]
assert recall_at_k(retrieved, relevant, k=2) == pytest.approx(1 / 3)
assert recall_at_k(retrieved, relevant, k=4) == pytest.approx(2 / 3)
def test_mrr():
assert mrr(["a", "b", "c"], ["c"]) == pytest.approx(1 / 3)
assert mrr(["x", "y"], ["z"]) == 0.0
def test_ndcg_at_k_perfect_order():
qrels = {"a": 2, "b": 1}
assert ndcg_at_k(["a", "b"], qrels, k=2) == pytest.approx(1.0)
def test_ndcg_at_k_irrelevant_first():
qrels = {"a": 2, "b": 1}
# Wrong order should still be > 0 but < 1
val = ndcg_at_k(["c", "a", "b"], qrels, k=3)
assert 0 < val < 1
def test_score_run_aggregates_across_queries():
scores = score_run(
per_query_retrieved={"q1": ["a", "b"], "q2": ["x", "y", "z"]},
per_query_qrels={"q1": {"a": 1}, "q2": {"z": 2}},
ks=(1, 5),
ndcg_k=5,
)
assert scores.n_queries == 2
assert scores.recall_at_k[1] == pytest.approx((1 + 0) / 2) # q1 hits @1, q2 doesn't
assert scores.mrr == pytest.approx((1.0 + 1 / 3) / 2)