sentiment-scope / evals /tests /test_run_ai_detect_eval.py
melkholy's picture
Ship AI text detection: enable 5 models, bake detectors, docs
8392b7d verified
Raw
History Blame Contribute Delete
9.56 kB
"""Unit tests for the torch-free AI-detector eval helpers.
Like ``test_run_eval``, these import only ``run_ai_detect_eval`` — never
``app`` or torch — because the heavy detector imports live inside
``run_ai_detect_eval.run()``. The cross-detector helpers (disagreement,
pairwise agreement, calibration) are the whole reason this module exists, so
they get the most coverage.
"""
import pytest
import run_ai_detect_eval as ade
# --- Slice A: CSV parsing (source_type column, not category) ------------------
_GOOD_CSV = (
"id,text,true_label,source_type,notes\n"
"1,ok sounds good,human,human_short,casual\n"
'2,"As an AI language model, I can help",ai,ai_obvious,self-reference\n'
)
def test_parse_reads_source_type_and_fields():
rows = ade.parse_detector_rows(_GOOD_CSV)
assert len(rows) == 2
assert rows[0].id == "1"
assert rows[0].text == "ok sounds good"
assert rows[0].true_label == "human"
assert rows[0].source_type == "human_short"
# Quoted field with an embedded comma survives intact.
assert rows[1].text == "As an AI language model, I can help"
assert rows[1].source_type == "ai_obvious"
def test_parse_missing_required_column_raises():
bad = "id,text,source_type\n1,hi,human_short\n" # no true_label
with pytest.raises(ValueError) as exc:
ade.parse_detector_rows(bad)
assert "true_label" in str(exc.value)
def test_parse_rejects_overlong_text_without_truncating():
long_text = "x" * (ade.MAX_TEXT_CHARS + 1)
bad = f"id,text,true_label\n1,{long_text},human\n"
with pytest.raises(ValueError) as exc:
ade.parse_detector_rows(bad)
assert str(ade.MAX_TEXT_CHARS) in str(exc.value)
def test_parse_skips_blank_text_rows():
csv_text = "id,text,true_label\n1,,human\n2,real text,ai\n"
rows = ade.parse_detector_rows(csv_text)
assert [r.text for r in rows] == ["real text"]
def test_parse_defaults_optional_metadata():
rows = ade.parse_detector_rows("id,text,true_label\n1,hi,human\n")
assert rows[0].source_type == ""
assert rows[0].notes == ""
# --- Slice B: disagreement (mirror ai_detect_compare: len({labels}) > 1) ------
_LABELS_BY_DET = {
# row: 0 1 2 3
"desklib": ["ai", "human", "ai", "human"],
"fakespot": ["ai", "ai", "ai", "human"],
"oxidane": ["ai", "human", "human", "human"],
}
def test_disagreement_flags_per_row():
d = ade.compute_disagreement(_LABELS_BY_DET)
# row0 all "ai" -> agree; row1 ai/human/human -> disagree;
# row2 ai/ai/human -> disagree; row3 all human -> agree.
assert d["flags"] == [False, True, True, False]
def test_disagreement_rate_is_fraction_of_rows():
d = ade.compute_disagreement(_LABELS_BY_DET)
assert d["rate"] == pytest.approx(0.5)
def test_disagreement_single_detector_never_disagrees():
d = ade.compute_disagreement({"only": ["ai", "human", "ai"]})
assert d["flags"] == [False, False, False]
assert d["rate"] == pytest.approx(0.0)
# --- Slice C: pairwise agreement ---------------------------------------------
def test_pairwise_agreement_all_unordered_pairs():
pairs = ade.pairwise_agreement(_LABELS_BY_DET)
# 3 detectors -> 3 unordered pairs.
assert len(pairs) == 3
lookup = {(p["detector_a"], p["detector_b"]): p["agreement"] for p in pairs}
# desklib vs fakespot: rows [T,F,T,T] match -> 3/4.
assert lookup[("desklib", "fakespot")] == pytest.approx(0.75)
# desklib vs oxidane: rows [T,T,F,T] match -> 3/4.
assert lookup[("desklib", "oxidane")] == pytest.approx(0.75)
# fakespot vs oxidane: rows [T,F,F,T] match -> 2/4.
assert lookup[("fakespot", "oxidane")] == pytest.approx(0.5)
# --- Slice D: calibration hint (mean P(ai) on human rows vs ai rows) ---------
def test_calibration_hint_splits_by_true_label():
true_labels = ["human", "human", "ai", "ai"]
p_ai = {"desklib": [0.1, 0.3, 0.8, 0.9]}
cal = ade.calibration_hint(true_labels, p_ai)
assert cal["desklib"]["mean_p_ai_on_human"] == pytest.approx(0.2)
assert cal["desklib"]["mean_p_ai_on_ai"] == pytest.approx(0.85)
# A well-behaved detector separates the classes -> positive gap.
assert cal["desklib"]["gap"] == pytest.approx(0.65)
def test_calibration_hint_handles_missing_class():
# No ai rows in the set -> mean_p_ai_on_ai is None, not a crash.
cal = ade.calibration_hint(["human", "human"], {"d": [0.2, 0.4]})
assert cal["d"]["mean_p_ai_on_human"] == pytest.approx(0.3)
assert cal["d"]["mean_p_ai_on_ai"] is None
assert cal["d"]["gap"] is None
# --- Slice E: disagreement + wrong-example collectors ------------------------
def _rows():
return [
ade.DetectorRow("1", "ok sounds good", "human", "human_short", "casual"),
ade.DetectorRow("2", "As an AI language model", "ai", "ai_obvious", "self-ref"),
ade.DetectorRow("3", "sounds-AI human line", "human", "ambiguous", "bait"),
]
def test_collect_disagreement_examples_only_flagged_rows():
rows = _rows()
labels_by_det = {
"desklib": ["human", "ai", "ai"],
"oxidane": ["human", "ai", "human"],
}
p_ai_by_det = {"desklib": [0.1, 0.9, 0.8], "oxidane": [0.2, 0.95, 0.4]}
flags = ade.compute_disagreement(labels_by_det)["flags"]
ex = ade.collect_disagreement_examples(rows, labels_by_det, p_ai_by_det, flags)
# Only row 3 (index 2) disagrees.
assert len(ex) == 1
assert ex[0]["id"] == "3"
assert ex[0]["true"] == "human"
per_det = {d["detector"]: (d["label"], d["p_ai"]) for d in ex[0]["per_detector"]}
assert per_det["desklib"] == ("ai", pytest.approx(0.8))
assert per_det["oxidane"] == ("human", pytest.approx(0.4))
def test_collect_wrong_examples_only_mismatches():
rows = _rows()
y_pred = ["human", "ai", "ai"] # row 3 human misread as ai
p_ai = [0.1, 0.9, 0.8]
wrong = ade.collect_wrong_examples(rows, y_pred, p_ai)
assert len(wrong) == 1
assert wrong[0]["true"] == "human"
assert wrong[0]["predicted"] == "ai"
assert wrong[0]["source_type"] == "ambiguous"
assert wrong[0]["p_ai"] == pytest.approx(0.8)
# --- Slice F: summary + report -----------------------------------------------
_WARNING = "PLACEHOLDER WARNING — sourced from app.routes in the real run."
def _detector_results():
return {
"desklib": {
"name": "desklib-ai-text-detector-v1.01",
"y_pred": ["human", "ai", "ai"],
"p_ai": [0.1, 0.9, 0.8],
"latencies_ms": [40.0, 42.0, 50.0],
},
"oxidane": {
"name": "oxidane-tmr-ai-text-detector",
"y_pred": ["human", "ai", "human"],
"p_ai": [0.2, 0.95, 0.4],
"latencies_ms": [30.0, 31.0, 38.0],
},
}
def test_build_summary_shape_and_cross_detector_fields():
summary = ade.build_detector_summary(
data_file="evals/data/ai_detection_eval.csv",
rows=_rows(),
detector_results=_detector_results(),
warning=_WARNING,
)
for key in (
"data_file", "n_examples", "labels", "detectors", "disagreement_rate",
"disagreement_examples", "pairwise_agreement", "calibration", "warning",
):
assert key in summary
assert summary["n_examples"] == 3
assert summary["labels"] == ["human", "ai"]
# Per-detector metrics are present and shaped.
desk = summary["detectors"]["desklib"]
for key in ("accuracy", "macro_f1", "latency_p50_ms", "latency_p95_ms",
"per_class", "confusion_matrix", "wrong_examples"):
assert key in desk
# Only row 3 disagrees -> rate 1/3 (rounded to 4dp in the summary).
assert summary["disagreement_rate"] == pytest.approx(1 / 3, abs=1e-4)
assert len(summary["disagreement_examples"]) == 1
assert summary["warning"] == _WARNING
def test_render_report_contains_all_required_sections():
summary = ade.build_detector_summary(
data_file="d.csv", rows=_rows(),
detector_results=_detector_results(), warning=_WARNING,
)
md = ade.render_detector_report(summary)
# Both honest texts are required and distinct.
assert "not proof of authorship" in md # brief's report paragraph
assert _WARNING in md # API warning, verbatim
# Provenance disclosure: BOTH classes are AI-authored (the "human" rows are
# AI-written stand-ins), and this is explicitly not a benchmark.
assert "ai assistant" in md.lower() and "benchmark" in md.lower()
# Core sections.
assert "Disagreement" in md
assert "Pairwise agreement" in md
assert "Calibration" in md
# Disagreement example table shows each detector's label + P(ai).
assert "P(ai)" in md
# Per-detector accuracy and latency reported.
assert "Accuracy" in md and "p50" in md and "p95" in md
def test_render_report_no_disagreement_states_it():
# All detectors agree on every row -> the report should say so, not crash.
results = {
"a": {"name": "A", "y_pred": ["human", "ai"], "p_ai": [0.1, 0.9],
"latencies_ms": [10.0, 11.0]},
"b": {"name": "B", "y_pred": ["human", "ai"], "p_ai": [0.2, 0.8],
"latencies_ms": [12.0, 13.0]},
}
rows = [
ade.DetectorRow("1", "x", "human", "human_short", ""),
ade.DetectorRow("2", "y", "ai", "ai_obvious", ""),
]
summary = ade.build_detector_summary("d.csv", rows, results, _WARNING)
assert summary["disagreement_rate"] == pytest.approx(0.0)
md = ade.render_detector_report(summary)
assert "Disagreement" in md