arborist/tests/test_relevance_shadow.py
russell@unturf.com 5f4f4ceb1c
#000052: more tests for §3.1 + §3.2 — bench-max the detectors against real data
§3.1 diagnose_coherence (now 19 tests, +10 from the parallel session's 9):
- 3 more positive shapes (multi-sentence vacuous, named-entity circular,
  grammar-term phrase_component_reuse).
- 5 xfail regression tests for SHAPES THAT FALSE-FIRE on real bench-qa
  STRICT data (44/808 = 5.4% FP rate measured on the pooled n=1+3+5
  STRICT answers). Each xfail names the exact shape + why it should
  ideally be 'ok' + which rule needs tightening:
    * 'The chemical symbol for gold is Au.' → vacuous (short predicate)
    * 'Michael Jordan's Restaurant was a restaurant ... named after
      Michael Jordan.' → circular (named-after re-use)
    * 'The Western X was the western half of the X' → circular
    * 'The name <Phrase> is a translation ... of the <derivative>' →
      phrase_component_reuse (translation/etymology)
    * claim-lattice [E1 | … …"] tails → vacuous (truncated bracket
      fragment)
    * 'The term <X>' → phrase_component_reuse (idiomatic English)
- 1 load-bearing real-traffic test: FP rate on 808-cell pooled STRICT
  must stay ≤ 7% (current 5.4%) — fires loud if a future change
  regresses it. Skips on fresh-checkout (bench/qa_results/ gitignored).

§3.2 ShadowRelevance (now 20 tests, +7 from the round-1 scaffold):
- Manifest tests for round-2 primary (bge-reranker-large), the size
  spectrum coverage (50-560MB), the candidate-bench findings block
  (biggest-within-family / not-across-families / deeper-not-better /
  capacity-floor).
- Pair-kind distinction (question_answer vs claim_source recorded
  separately for downstream telemetry / governance hashing).
- Batch-order preservation (_score_batch must return scores in input
  order — load-bearing for downstream zip-back).
- Empty-input handling (Q empty, D empty, whitespace-only).
- Zionist-entity discriminator sanity (on-topic > off-topic logit).
- demote_below_score-stays-null invariant (the §7 #18→#27 discipline:
  no hardcoded threshold; must come from a real-traffic shadow sweep).

Total: 101 passed + 6 xfailed (5 §3.1 regressions documented + 1 from
parallel session). The 5 xfails are the bench-maxing receipts — they
document EXACTLY which shapes §3.1 false-fires on, with the rule that
needs tightening named in each reason.
2026-05-13 12:38:09 -04:00

251 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for the #000052 §3.2 relevance shadow scaffold.
Mirrors `tests/test_nli_shadow.py`'s discipline: pure-Python parts +
graceful degradation when the ``[nli]`` extra is absent. The real
model path is exercised opportunistically (skipped when deps missing).
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from arborist.qa.relevance import (
ShadowRelevance, ShadowRelevanceResult, load_manifest,
shadow_check_question_answer, shadow_check_claim_source,
)
from arborist.qa.relevance.shadow import _resolve_relevance_index
# --- manifest --------------------------------------------------------------
def test_manifest_has_required_fields():
m = load_manifest()
for key in ("relevance_model_version", "hf_repo", "license", "max_length",
"score_shape", "alternates", "demote_below_score",
"demote_below_score_comment"):
assert key in m, key
# demote_below_score MUST be null until bench-set (§7 #18→#27 lesson)
assert m["demote_below_score"] is None
def test_manifest_is_valid_json_file():
p = Path(__file__).resolve().parents[1] / "arborist" / "qa" / "relevance" / "manifest.json"
json.loads(p.read_text()) # raises on malformed
# --- label-index resolution ------------------------------------------------
@pytest.mark.parametrize("id2label,expect", [
({0: "LABEL_0"}, 0), # single-output reranker (typical)
({0: "irrelevant", 1: "relevant"}, 1), # 2-class with "relevant" label
({0: "negative", 1: "positive"}, 1), # 2-class with "positive" label
({0: "LABEL_0", 1: "LABEL_1"}, 1), # fallback to "label_1" substring
({0: "score_0", 1: "score_1"}, 0), # no relevant/positive → fallback to 0
])
def test_resolve_relevance_index(id2label, expect):
assert _resolve_relevance_index(id2label) == expect
# --- ShadowRelevance contract ----------------------------------------------
def test_shadowrelevance_construction_never_raises_and_starts_unavailable():
r = ShadowRelevance()
assert r.available is False
assert r.model_version == load_manifest()["relevance_model_version"]
assert r.demote_below_score is None # no hardcoded threshold (§7 #18→#27 discipline)
assert r.device_pref in ("auto", "cpu", "cuda")
assert r.batch_size >= 1
assert r.backend is None
# _ensure_loaded never raises even when the extra is missing
r._ensure_loaded()
assert r.available in (True, False)
def test_shadow_check_question_answer_returns_result_and_degrades_gracefully():
res = shadow_check_question_answer("who painted the mona lisa?",
"Leonardo da Vinci painted the Mona Lisa.")
assert isinstance(res, ShadowRelevanceResult)
assert res.pair_kind == "question_answer"
assert res.would_demote in (True, False)
d = res.as_dict()
assert set(d) >= {"available", "relevance_score", "would_demote",
"demote_below_score", "model_version", "pair_kind", "reason"}
if not res.available:
assert res.would_demote is False
assert "deps_missing" in res.reason or "load_failed" in res.reason
def test_shadow_check_claim_source_returns_result_and_degrades_gracefully():
res = shadow_check_claim_source("Leonardo da Vinci painted the Mona Lisa.",
"The Mona Lisa is a half-length portrait painting by Italian artist Leonardo da Vinci.")
assert isinstance(res, ShadowRelevanceResult)
assert res.pair_kind == "claim_source"
def test_no_hardcoded_threshold_means_would_demote_stays_False():
# the §7 #18→#27 discipline: demote_below_score must be null until
# a shadow sweep sets it. The contract: would_demote=False until then.
r = ShadowRelevance()
r._ensure_loaded()
if not r.available:
pytest.skip("[nli] extra not installed — would_demote-False path only reachable when loaded")
# any pair the model scores should still produce would_demote=False
# because demote_below_score is None at scaffold time
res = r.check_question_answer("capital of france?", "Paris.")
assert res.would_demote is False
assert res.demote_below_score is None
def test_empty_input_when_available_is_no_demote():
r = ShadowRelevance()
r._ensure_loaded()
if not r.available:
pytest.skip("[nli] extra not installed")
assert r.check_question_answer("", "non-empty answer").reason == "empty_input"
assert r.check_question_answer("non-empty question", "").reason == "empty_input"
def test_zionist_entity_field_case_when_available():
"""The motivating case (#000052 §1): a garbled claim about 'phrase
used as the entity' against a 'Zionist entity' source. The
relevance score for the (claim, source) pair should be LOWER than
for a well-aligned (claim, source) pair — that is the signal the
veto would act on if the threshold were set."""
r = ShadowRelevance()
r._ensure_loaded()
if not r.available:
pytest.skip("[nli] extra not installed")
# off-topic: a claim about the abstract noun 'entity' against a source about Israel
off = r.check_claim_source(
"the phrase 'Zionist entity' is sometimes used as the entity, referring to the State of Israel",
"Wikipedia article 'Phrase' — In grammar, a phrase is a group of words functioning as a single unit in the syntax of a sentence.",
)
on = r.check_claim_source(
"the phrase 'Zionist entity' is sometimes used as a pejorative for the State of Israel",
"Zionist entity () is a phrase sometimes used by Arabs and Muslims as a pejorative for the State of Israel.",
)
# both available
assert off.available and on.available
# on-topic should score strictly higher than off-topic — that's the discriminator
assert on.relevance_score > off.relevance_score
# --- broader §3.2 coverage (round-2 candidate-bench, 2026-05-13) ----------
# Added after extending the size spectrum 30MB→560MB (#000052 §3.2.1
# round-2): more tests covering pair-kind-specific behavior, batch
# correctness, and the new manifest entries.
def test_manifest_round2_primary_is_bge_reranker_large():
m = load_manifest()
assert m["relevance_model_version"] == "relevance-shadow-v1-bge-reranker-large"
assert m["hf_repo"] == "BAAI/bge-reranker-large"
# the round-2 candidate-bench block is recorded
assert "candidate_bench_results_2026_05_13_round2" in m
cb = m["candidate_bench_results_2026_05_13_round2"]
assert cb["rank_by_sep_margin"][0]["model"] == "bge-reranker-large"
# capacity-floor finding is documented
floor = cb["rank_by_sep_margin"][-1]
assert floor["model"] == "ms-marco-MiniLM-L-2-v2"
assert floor["margin"] < 0 # NOT clean-separable
# findings block exists with the key science
assert "findings" in cb
for key in ("biggest_is_best_within_a_family", "but_not_across_families",
"deeper_is_not_better_within_MS_MARCO_MiniLM",
"capacity_floor_between_L_2_and_L_4"):
assert key in cb["findings"]
def test_manifest_alternates_cover_size_spectrum():
m = load_manifest()
sizes = sorted(a["approx_mb"] for a in m["alternates"])
# the alternates must span the spectrum from ≤50MB (small) to ≥280MB (large)
assert sizes[0] <= 50, f"smallest alternate is {sizes[0]}MB — needs a ≤50MB datapoint"
assert sizes[-1] >= 280, f"largest alternate is {sizes[-1]}MB — needs a ≥280MB datapoint"
def test_pair_kind_recorded_separately_for_qa_vs_claim_source():
# The result records WHICH method produced the score — same model,
# same primitive, but the bookkeeping must distinguish the two
# for downstream telemetry / governance hashing.
r = ShadowRelevance()
res_qa = r.check_question_answer("who painted the mona lisa?",
"Leonardo da Vinci painted the Mona Lisa.")
res_cs = r.check_claim_source("Leonardo da Vinci painted the Mona Lisa.",
"The Mona Lisa is a portrait by Leonardo da Vinci.")
assert res_qa.pair_kind == "question_answer"
assert res_cs.pair_kind == "claim_source"
def test_batch_score_preserves_input_order():
"""_score_batch returns scores in input order (any padding/batching
must not reorder). This is load-bearing for downstream bench
scripts that zip scores back onto input records."""
r = ShadowRelevance()
r._ensure_loaded()
if not r.available:
pytest.skip("[nli] extra not installed")
pairs = [
("very short", "very short"),
("query about the painting Mona Lisa by Leonardo da Vinci in the 16th century",
"Leonardo da Vinci painted the Mona Lisa around 15031519, an Italian Renaissance portrait."),
("a", "b"),
]
a = r._score_batch(pairs)
# scoring each pair individually should give the same answers (modulo
# padding noise, which for fp32 should be 0)
individual = [r._score_batch([p])[0] for p in pairs]
assert len(a) == 3 == len(individual)
for batched, single in zip(a, individual):
# padding can introduce tiny numerical drift on fp16/fp32; allow
# 0.05 logit slack (well below any threshold we'd ever set)
assert abs(batched - single) < 0.05, f"batched {batched} vs single {single}"
def test_empty_string_query_or_document_returns_empty_input():
r = ShadowRelevance()
r._ensure_loaded()
if not r.available:
pytest.skip("[nli] extra not installed")
assert r.check_question_answer("", "some answer").reason == "empty_input"
assert r.check_claim_source("a claim", "").reason == "empty_input"
# whitespace-only too
assert r.check_question_answer(" ", "answer").reason == "empty_input"
def test_score_sign_distinguishes_on_topic_from_off_topic():
"""Sanity: on the Zionist field case (the motivating example),
the on-topic (claim, source) pair MUST score strictly higher than
the off-topic one. This is the load-bearing discrimination — if
this assertion fails on the manifest's primary, the whole §3.2
approach is invalidated and we walk back."""
r = ShadowRelevance()
r._ensure_loaded()
if not r.available:
pytest.skip("[nli] extra not installed")
on = r.check_claim_source(
"the phrase 'Zionist entity' is sometimes used as a pejorative for the State of Israel",
"Zionist entity () is a phrase sometimes used by Arabs and Muslims as a pejorative for the State of Israel.",
)
off = r.check_claim_source(
"the phrase 'Zionist entity' is sometimes used as the entity, referring to the State of Israel",
"In grammar, a phrase is a group of words functioning as a single unit in the syntax of a sentence.",
)
assert on.available and off.available
assert on.relevance_score > off.relevance_score
def test_demote_below_score_remains_null_until_bench_sets_it():
"""Load-bearing invariant from #000049 §7 #18→#27: the demote
threshold MUST be set by a real-traffic shadow sweep, not by a
candidate-bench number or literature default. If anyone sets
demote_below_score in the manifest before step 2 lands, this
test fires."""
m = load_manifest()
assert m["demote_below_score"] is None, \
"demote_below_score must be null until §3.2.2 step 2 sets it from a real-traffic shadow sweep"
# the round-2 candidate-bench DOES record candidate θ values, but
# explicitly notes they are not the runtime threshold
cb = m["candidate_bench_results_2026_05_13_round2"]
assert "note" in cb and "CANDIDATE-BENCH ONLY" in cb["note"]