Files
PlantAI-rag/tests/test_qa_v1_1_73_graph_path_selection.py

246 lines
10 KiB
Python

import inspect
from src.rag.qa_v1_1_73.graph_path_chain import GraphHop, GraphPath, NativeGraphPathRetriever
def hop(index: int, source: str, target: str, assertion: str) -> GraphHop:
return GraphHop(
hop_index=index,
from_id=source,
from_name=source,
to_id=target,
to_name=target,
source=source,
relation="related_to",
target=target,
assertion_id=assertion,
edge_score=0.8,
)
def path(*hops: GraphHop, score: float) -> GraphPath:
return GraphPath(
hops=list(hops),
node_ids=[hops[0].from_id] + [item.to_id for item in hops],
path_score=score,
score_breakdown={"path_coherence": 1.0, "provenance_confidence": 0.8},
)
def test_direct_path_is_not_suppressed_by_longer_path_with_different_terminal() -> None:
direct = path(hop(1, "A", "B", "a1"), score=0.95)
longer = path(hop(1, "A", "B", "a1"), hop(2, "B", "C", "a2"), score=0.99)
selected = NativeGraphPathRetriever._select_multihop_paths([direct, longer], top_k=10)
assert direct in selected
def test_top10_keeps_two_distinct_paths_with_same_endpoints() -> None:
first = path(hop(1, "A", "B", "a1"), hop(2, "B", "Z", "a2"), score=0.90)
second = path(hop(1, "A", "C", "b1"), hop(2, "C", "Z", "b2"), score=0.85)
selected = NativeGraphPathRetriever._select_multihop_paths([first, second], top_k=10)
assert selected == [first, second]
def test_quoted_relation_phrases_become_search_clues() -> None:
clues = NativeGraphPathRetriever._relation_clues(
'相关研究中,与 ash content 存在“determined by”关系的对象是什么?'
)
assert "determined by" in clues
assert "determined_by" in clues
def test_blocked_method_endpoint_is_retained_but_not_expanded_when_unmentioned() -> None:
method_hop = hop(3, "ash content", "AOAC", "a3")
method_hop.target_type = "method"
method_hop.relation_category = "method_tool"
terminal_path = path(method_hop, score=0.8)
assert NativeGraphPathRetriever._should_expand_path(terminal_path, "ash content determined by what") is False
assert NativeGraphPathRetriever._should_expand_path(terminal_path, "aoac used for analysis") is True
def test_start_node_selection_preserves_each_seed_before_ambiguous_fill() -> None:
rows = [
{"seed_index": 0, "canonical_id": f"na_{i}", "name": "NA", "entity_type": "unknown"}
for i in range(20)
] + [
{"seed_index": 1, "canonical_id": "gene_1", "name": "PgbZIP44", "entity_type": "gene"},
{"seed_index": 2, "canonical_id": "root_1", "name": "roots", "entity_type": "tissue"},
]
selected = NativeGraphPathRetriever._fair_seed_nodes(rows, limit=3)
assert [row["canonical_id"] for row in selected] == ["na_0", "gene_1", "root_1"]
def test_path_score_rewards_coverage_of_question_quoted_relations() -> None:
matching_hop = hop(1, "Na+", "safflower", "gold")
matching_hop.relation = "content_apparent_in"
matching = path(matching_hop, score=0.0)
distractor_hop = hop(1, "Na+", "root", "other")
distractor_hop.relation = "associated_with"
distractor = path(distractor_hop, score=0.0)
retriever = NativeGraphPathRetriever.__new__(NativeGraphPathRetriever)
matching_score, _ = retriever._score_path(
matching, "na content apparent in what", {"na"}, set(), ["content apparent in"]
)
distractor_score, _ = retriever._score_path(
distractor, "na content apparent in what", {"na"}, set(), ["content apparent in"]
)
assert matching_score > distractor_score
def test_only_seed_names_visible_in_question_become_endpoint_clues() -> None:
clues = NativeGraphPathRetriever._visible_entity_clues(
"l canum collected from gt using acetone",
["l canum", "gt", "acetone", "unrelated provisional guess"],
)
assert clues == ["l canum", "gt", "acetone"]
def test_beam_selection_preserves_a_candidate_from_each_start_anchor() -> None:
crowded = [path(hop(1, "A", f"X{i}", f"a{i}"), score=0.9 - i * 0.01) for i in range(5)]
second_anchor = path(hop(1, "B", "Y", "b1"), score=0.2)
selected = NativeGraphPathRetriever._select_beam_paths(crowded + [second_anchor], beam_width=2)
assert selected == [crowded[0], second_anchor]
def test_beam_prefers_paths_reaching_question_visible_bridge() -> None:
distractor = path(hop(1, "A", "motif", "a1"), score=0.95)
visible_bridge = path(hop(1, "A", "Senna", "a2"), score=0.60)
selected = NativeGraphPathRetriever._select_beam_paths(
[distractor, visible_bridge], beam_width=1, entity_clues=["senna"]
)
assert selected == [visible_bridge]
def test_neighbor_query_limits_directed_outgoing_edges_not_undirected_noise() -> None:
source = inspect.getsource(NativeGraphPathRetriever._fetch_neighbors)
assert "a.source_canonical_id = e.canonical_id" in source
assert "n.canonical_id = a.target_canonical_id" in source
assert "a.target_canonical_id <> e.canonical_id" in source
def test_start_node_query_supports_punctuation_normalized_abbreviations() -> None:
source = inspect.getsource(NativeGraphPathRetriever._resolve_start_nodes)
assert "replace(e.name_norm, '.', '') = seed_name" in source
def test_stitch_paths_joins_exact_canonical_endpoint_without_inventing_edges() -> None:
prefix = path(hop(1, "A", "B", "a1"), hop(2, "B", "C", "a2"), score=0.8)
suffix = path(hop(1, "C", "D", "a3"), score=0.9)
stitched = NativeGraphPathRetriever._stitch_paths([prefix, suffix], max_hops=4)
assert len(stitched) == 1
assert stitched[0].node_ids == ["A", "B", "C", "D"]
assert stitched[0].assertion_ids() == ["a1", "a2", "a3"]
def test_stitch_paths_rejects_cycles_and_overlong_paths() -> None:
prefix = path(hop(1, "A", "B", "a1"), hop(2, "B", "C", "a2"), score=0.8)
cycle = path(hop(1, "C", "A", "a3"), score=0.9)
long_suffix = path(
hop(1, "C", "D", "b1"), hop(2, "D", "E", "b2"), hop(3, "E", "F", "b3"),
score=0.9,
)
assert NativeGraphPathRetriever._stitch_paths([prefix, cycle, long_suffix], max_hops=4) == []
def test_terminal_type_scoring_prefers_unknown_classification_answer_over_known_bridge() -> None:
known_bridge_hop = hop(2, "cage", "pilose beggarticks", "a2")
known_bridge_hop.target_type = "species"
known_bridge = path(known_bridge_hop, score=0.0)
answer_hop = hop(3, "pilose beggarticks", "Bidens pilosa", "a3")
answer_hop.target_type = "species"
answer = path(answer_hop, score=0.0)
question = "pilose beggarticks belongs to what family category or subclass"
assert NativeGraphPathRetriever._target_type_match(
answer, question, ["classification"]
) > NativeGraphPathRetriever._target_type_match(
known_bridge, question, ["classification"]
)
def test_path_score_rewards_coverage_of_visible_bridge_entities() -> None:
via_bridge = path(
hop(1, "Salvia", "ITS2", "a1"),
hop(2, "ITS2", "Senna", "a2"),
hop(3, "Senna", "mucilage", "a3"),
score=0.0,
)
distractor = path(
hop(1, "Salvia", "ITS2", "a1"),
hop(2, "ITS2", "helix", "b2"),
score=0.0,
)
retriever = NativeGraphPathRetriever.__new__(NativeGraphPathRetriever)
args = ("salvia its2 senna contains what", {"salvia"}, set(), ["contains"], [], ["salvia", "its2", "senna"])
via_score, _ = retriever._score_path(via_bridge, *args)
distractor_score, _ = retriever._score_path(distractor, *args)
assert via_score > distractor_score
def test_top10_treats_visible_bridge_coverage_as_hard_ranking_constraint() -> None:
complete = path(hop(1, "A", "B", "a1"), hop(2, "B", "C", "a2"), score=0.60)
complete.score_breakdown["visible_entity_coverage"] = 1.0
distractor = path(hop(1, "A", "X", "b1"), score=0.99)
distractor.score_breakdown["visible_entity_coverage"] = 0.33
selected = NativeGraphPathRetriever._select_multihop_paths([distractor, complete], top_k=1)
assert selected == [complete]
def test_top10_prefers_complete_chain_over_its_equally_visible_suffix() -> None:
complete = path(
hop(1, "A", "B", "a1"), hop(2, "B", "C", "a2"),
hop(3, "C", "D", "a3"), hop(4, "D", "answer", "a4"), score=0.80,
)
suffix = path(
hop(1, "B", "C", "a2"), hop(2, "C", "D", "a3"),
hop(3, "D", "other", "b4"), score=0.99,
)
complete.score_breakdown["visible_entity_coverage"] = 1.0
suffix.score_breakdown["visible_entity_coverage"] = 1.0
selected = NativeGraphPathRetriever._select_multihop_paths([suffix, complete], top_k=1)
assert selected == [complete]
def test_question_chain_spec_compiles_explicit_pairs_and_terminal_relation() -> None:
question = (
"AhbHLH109.4 在 Silihong中表达;Silihong 通过 A. duranensis进行研究;"
"研究报道 A. duranensis 与 Hsf genes 存在“has gene family”关系。"
"相关研究中,与 Hsf genes 存在“analyzed for”关系的对象是什么?"
)
pairs, terminal = NativeGraphPathRetriever._question_chain_spec(question)
assert pairs == [
("ahbhlh109 4", "silihong"),
("silihong", "a duranensis"),
("a duranensis", "hsf genes"),
]
assert terminal == ("hsf genes", "analyzed for")
def test_question_chain_spec_handles_interaction_and_non_object_terminal_forms() -> None:
pairs, terminal = NativeGraphPathRetriever._question_chain_spec(
"PcNF-YA5 与 miR169相互作用;研究报道 miR169 与 NFYA5 存在“targets”关系;"
"NFYA5 调控 drought-responsive genes。drought-responsive genes 属于什么家族、类别或亚类?"
)
assert pairs == [
("pcnf ya5", "mir169"), ("mir169", "nfya5"),
("nfya5", "drought responsive genes"),
]
assert terminal == ("drought responsive genes", "")
def test_question_chain_spec_preserves_chemical_charge_in_entity_names() -> None:
pairs, terminal = NativeGraphPathRetriever._question_chain_spec(
"PgbZIP44 在 roots中表达;研究报道 roots 与 Na+ 存在“site of accumulation”关系。"
"相关研究中,与 Na+ 存在“content apparent in”关系的对象是什么?"
)
assert pairs[-1] == ("roots", "na+")
assert terminal == ("na+", "content apparent in")
def test_question_chain_spec_handles_treatment_or_environment_terminal() -> None:
pairs, terminal = NativeGraphPathRetriever._question_chain_spec(
"研究报道 parents 与 PH 存在“showed phenotypic variation for”关系。"
"PH 与什么处理或环境条件有关?"
)
assert pairs == [("parents", "ph")]
assert terminal == ("ph", "")