Spaces:
Running
Running
| """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 | |