diff --git a/.gitignore b/.gitignore index 6f3a11cf3..f73387b92 100644 --- a/.gitignore +++ b/.gitignore @@ -75,3 +75,9 @@ venv sample_names.txt *.log +# local uv/python tooling (not part of the ARAX codebase) +pyproject.toml +uv.lock +.python-version +PLAN.md + diff --git a/code/ARAX/ARAXQuery/ARAX_filter_kg.py b/code/ARAX/ARAXQuery/ARAX_filter_kg.py index adbd8643c..b14c59757 100644 --- a/code/ARAX/ARAXQuery/ARAX_filter_kg.py +++ b/code/ARAX/ARAXQuery/ARAX_filter_kg.py @@ -1,5 +1,6 @@ import sys import traceback +from typing import Self from collections import Counter from collections.abc import Hashable from Filter_KG.remove_edges import RemoveEdges @@ -25,6 +26,7 @@ def __init__(self): 'remove_nodes_by_property', 'remove_nodes_by_category', 'remove_edges_by_discrete_attribute', + 'remove_edges_by_statistical_significance', 'remove_orphaned_nodes', 'remove_general_concept_nodes' } @@ -76,6 +78,14 @@ def __init__(self): "type": "string", "description": "The name of the edge attribute to filter on." } + self.minimum_significance_info = { + "is_required": True, + "enum": ['very_strongly_significant', 'strongly_significant', 'significant', 'suggestive', 'not_significant'], + "type": "string", + "description": "The minimum statistical significance band to keep. " + "Edges with a significance qualifier below this band are removed. " + "Edges without a qualifier are always kept." + } self.direction_info = { "is_required": True, "enum": ['above', 'below'], @@ -264,6 +274,36 @@ def __init__(self): "qedge_keys": self.qedge_key_info } }, + "remove_edges_by_statistical_significance": { + "dsl_command": "filter_kg(action=remove_edges_by_statistical_significance)", + "description": """ +`remove_edges_by_statistical_significance` removes edges from the knowledge graph (KG) whose +`biolink:statistical_significance_qualifier` falls below a specified minimum band. + +This is an **ordinal** filter: setting `minimum_significance=significant` removes edges +labelled `suggestive` or `not_significant`, while keeping `significant`, `strongly_significant`, +and `very_strongly_significant`. + +**Important:** Edges that do not carry the qualifier at all are **kept** (not penalized), +since the qualifier is still being rolled out across knowledge sources. + +Use cases include: + +* removing all non-significant edges: `filter_kg(action=remove_edges_by_statistical_significance, minimum_significance=suggestive)` +* keeping only strongly significant evidence: `filter_kg(action=remove_edges_by_statistical_significance, minimum_significance=strongly_significant)` + +You have the option (defaults to false) to remove connected nodes via `remove_connected_nodes=t`. + """, + 'brief_description': """ +remove_edges_by_statistical_significance removes edges whose statistical significance qualifier falls below a minimum band. + """, + "parameters": { + "minimum_significance": self.minimum_significance_info, + "remove_connected_nodes": self.remove_connected_nodes_info, + "qnode_keys": self.qnode_key_info, + "qedge_keys": self.qedge_key_info + } + }, "remove_edges_by_std_dev": { "dsl_command": "filter_kg(action=remove_edges_by_std_dev)", "description": """ @@ -753,6 +793,77 @@ def __remove_edges_by_discrete_attribute(self, describe=False): response = RE.remove_edges_by_property() return response + def __remove_edges_by_statistical_significance(self: Self, describe: bool = False): + """ + Removes edges from the KG whose statistical_significance_qualifier falls below + the specified minimum band. Edges without the qualifier are kept. + """ + message = self.message + parameters = self.parameters + + significance_values = {'very_strongly_significant', 'strongly_significant', + 'significant', 'suggestive', 'not_significant'} + + if message and parameters and hasattr(message, 'query_graph') and hasattr(message.query_graph, 'edges'): + known_values = set() + _sig_type_id = "biolink:statistical_significance_qualifier" + for edge in message.knowledge_graph.edges.values(): + if edge.qualifiers: + for q in edge.qualifiers: + if q.qualifier_type_id == _sig_type_id: + val = q.qualifier_value.replace("biolink:", "") if isinstance(q.qualifier_value, str) and q.qualifier_value.startswith("biolink:") else q.qualifier_value + known_values.add(val) + allowable_parameters = {'action': {'remove_edges_by_statistical_significance'}, + 'minimum_significance': significance_values, + 'remove_connected_nodes': {'true', 'false', 'True', 'False', 't', 'f', 'T', 'F'}, + 'qnode_keys': set([t for x in self.message.knowledge_graph.nodes.values() if x.qnode_keys is not None for t in x.qnode_keys]), + 'qedge_keys': set([t for x in self.message.knowledge_graph.edges.values() if x.qedge_keys is not None for t in x.qedge_keys]) + } + else: + allowable_parameters = {'action': {'remove_edges_by_statistical_significance'}, + 'minimum_significance': significance_values, + 'remove_connected_nodes': {'true', 'false', 'True', 'False', 't', 'f', 'T', 'F'}, + 'qnode_keys': {'a specific query node id to remove'}, + 'qedge_keys': {'a list of specific query edge ids to remove'} + } + + if describe: + brief_description = self.command_definitions['remove_edges_by_statistical_significance'] + allowable_parameters['brief_description'] = brief_description + return allowable_parameters + + # FW: patch to allow qnode_key to be backwards compatible: + if 'qnode_key' in self.parameters and 'qnode_keys' not in self.parameters: + self.parameters['qnode_keys'] = [self.parameters['qnode_key']] + + resp = self.check_params(allowable_parameters) + if self.response.status != 'OK' or resp == -1: + return self.response + + edge_params = self.parameters + if 'remove_connected_nodes' in edge_params: + value = edge_params['remove_connected_nodes'] + if value in {'true', 'True', 't', 'T'}: + edge_params['remove_connected_nodes'] = True + elif value in {'false', 'False', 'f', 'F'}: + edge_params['remove_connected_nodes'] = False + else: + self.response.error(f"Supplied value {value} is not permitted. In parameter remove_connected_nodes, allowable values are: {list(allowable_parameters['remove_connected_nodes'])}", + error_code="UnknownValue") + else: + edge_params['remove_connected_nodes'] = False + + if 'minimum_significance' not in edge_params: + self.response.error( + f"minimum_significance must be provided, allowable values are: {list(allowable_parameters['minimum_significance'])}", + error_code="UnknownValue") + if self.response.status != 'OK': + return self.response + + RE = RemoveEdges(self.response, self.message, edge_params) + response = RE.remove_edges_by_statistical_significance() + return response + def __remove_edges_by_continuous_attribute(self, describe=False): """ Removes edges from the KG. diff --git a/code/ARAX/ARAXQuery/ARAX_ranker.py b/code/ARAX/ARAXQuery/ARAX_ranker.py index 79bc1c4e3..ed29a0e53 100644 --- a/code/ARAX/ARAXQuery/ARAX_ranker.py +++ b/code/ARAX/ARAXQuery/ARAX_ranker.py @@ -11,7 +11,7 @@ import re -from typing import Union, Dict, Callable +from typing import Union, Dict, Callable, Optional, Self from ARAX_response import ARAXResponse from query_graph_info import QueryGraphInfo @@ -23,6 +23,22 @@ edge_confidence_manual_agent = 0.90 +# Score mapping for the statistical significance qualifier (conservative). +# TODO: Once all KGs populate statistical_significance_qualifier, revisit the +# trust weight (_significance_trust_weight) — the current conservative value +# accounts for the rollout asymmetry where qualifier-bearing edges are scored +# against edges that lack the qualifier entirely. +_significance_band_scores: dict[str, float] = { + "very_strongly_significant": 0.70, + "strongly_significant": 0.55, + "significant": 0.40, + "suggestive": 0.15, + "not_significant": 0.0, +} +_significance_qualifier_type_id: str = "biolink:statistical_significance_qualifier" +# How much to trust the significance qualifier signal (conservative during rollout). +_significance_trust_weight: float = 0.5 + def _get_query_graph_networkx_from_query_graph(query_graph: QueryGraph) -> nx.MultiDiGraph: query_graph_nx = nx.MultiDiGraph() @@ -376,15 +392,38 @@ def edge_attribute_score_combiner(self, edge_key, edge): # add more rules in the future continue - if len(edge_attribute_score_list) == 0: # if no appropriate attribute for score calculation, set the confidence to default base score (0.5) - edge_confidence = base - else: - edge_confidence = _calculate_final_individual_edge_confidence(base, edge_attribute_score_list) - else: + # Check for statistical significance qualifier (carried in edge.qualifiers) + sig_value = self._get_significance_qualifier_value(edge) + if sig_value is not None: + sig_score = _significance_band_scores.get(sig_value, 0.0) + if sig_score > 0: + edge_attribute_score_list.append( + sig_score * _significance_trust_weight + ) + + if len(edge_attribute_score_list) == 0: edge_confidence = base + else: + edge_confidence = _calculate_final_individual_edge_confidence(base, edge_attribute_score_list) return edge_confidence + def _get_significance_qualifier_value(self: Self, edge: Edge) -> Optional[str]: + """ + Look up the statistical significance qualifier from edge.qualifiers (the + TRAPI path for biolink qualifier descendants). Returns the bare enum value + or None if not present. + """ + # Check edge.qualifiers (expected path from Retriever/KPs) + if edge.qualifiers: + for q in edge.qualifiers: + if q.qualifier_type_id == _significance_qualifier_type_id: + value = q.qualifier_value + if isinstance(value, str) and value.startswith("biolink:"): + return value[len("biolink:"):] + return value + return None + def edge_attribute_score_normalizer(self, edge_attribute_name: str, edge_attribute_value) -> float: """ Takes an input edge attribute and value, dispatches it to the appropriate method that translates the value into diff --git a/code/ARAX/ARAXQuery/Filter_KG/remove_edges.py b/code/ARAX/ARAXQuery/Filter_KG/remove_edges.py index 5dcc6082d..20418ffde 100644 --- a/code/ARAX/ARAXQuery/Filter_KG/remove_edges.py +++ b/code/ARAX/ARAXQuery/Filter_KG/remove_edges.py @@ -5,6 +5,16 @@ import traceback +_significance_ordinal: dict[str, int] = { + "very_strongly_significant": 4, + "strongly_significant": 3, + "significant": 2, + "suggestive": 1, + "not_significant": 0, +} +_significance_qualifier_type_id: str = "biolink:statistical_significance_qualifier" + + class RemoveEdges: #### Constructor @@ -533,3 +543,127 @@ def remove_edges_by_stats(self): self.response.info("Edges successfully removed") return self.response + + def remove_edges_by_statistical_significance(self): + """ + Iterate over all edges in the knowledge graph, remove any edges whose + statistical_significance_qualifier falls below the specified minimum band. + Edges without the qualifier are kept (not penalized during rollout). + :return: response + """ + self.response.debug("Removing Edges") + edge_params = self.edge_parameters + self.response.info( + "Removing edges from the knowledge graph with statistical significance below " + f"{edge_params['minimum_significance']}") + message = self.message + kg = message.knowledge_graph + + try: + threshold_ordinal = _significance_ordinal.get(edge_params['minimum_significance']) + if threshold_ordinal is None: + self.response.error( + f"Invalid minimum_significance value '{edge_params['minimum_significance']}'. " + f"Allowable values: {list(_significance_ordinal.keys())}", + error_code="InvalidParameterValue") + return self.response + + edges_to_remove = set() + node_keys_to_remove = {} + edge_qid_dict = {} + for key, q_edge in self.message.query_graph.edges.items(): + edge_qid_dict[key] = {'subject': q_edge.subject, 'object': q_edge.object} + + # iterate over edges, find those below the significance threshold + for key, edge in kg.edges.items(): + sig_value = None + # Check edge.qualifiers (expected TRAPI path for biolink qualifier descendants) + if edge.qualifiers: + for q in edge.qualifiers: + if q.qualifier_type_id == _significance_qualifier_type_id: + sig_value = q.qualifier_value + if isinstance(sig_value, str) and sig_value.startswith("biolink:"): + sig_value = sig_value[len("biolink:"):] + break + + # Only remove edges that explicitly carry a below-threshold qualifier; + # edges without the qualifier are kept. + if sig_value is not None: + edge_ordinal = _significance_ordinal.get(sig_value) + if edge_ordinal is not None and edge_ordinal < threshold_ordinal: + edges_to_remove.add(key) + if edge_params.get('remove_connected_nodes', False): + for qedge_key in (getattr(edge, 'qedge_keys', None) or []): + if edge.subject not in node_keys_to_remove: + node_keys_to_remove[edge.subject] = {edge_qid_dict[qedge_key]['subject']} + else: + node_keys_to_remove[edge.subject].add(edge_qid_dict[qedge_key]['subject']) + if edge.object not in node_keys_to_remove: + node_keys_to_remove[edge.object] = {edge_qid_dict[qedge_key]['object']} + else: + node_keys_to_remove[edge.object].add(edge_qid_dict[qedge_key]['object']) + + if edge_params.get('remove_connected_nodes', False): + self.response.debug("Removing Nodes") + self.response.info("Removing connected nodes and their edges from the knowledge graph") + nodes_to_remove = set() + skipped_qnode_keys = set() + for key, node in kg.nodes.items(): + if key in node_keys_to_remove: + node_qnode_keys = getattr(node, 'qnode_keys', None) or [] + if 'qnode_keys' in edge_params: + if node_qnode_keys: + for param_qnode_key in edge_params['qnode_keys']: + if param_qnode_key in node_qnode_keys: + if len(node_qnode_keys) == 1: + nodes_to_remove.add(key) + else: + node_qnode_keys.remove(param_qnode_key) + node.qnode_keys = node_qnode_keys + else: + skipped_qnode_keys.add(key) + else: + skipped_qnode_keys.add(key) + else: + if len(node_qnode_keys) == 1: + nodes_to_remove.add(key) + else: + for node_key in node_keys_to_remove[key]: + node_qnode_keys.remove(node_key) + node.qnode_keys = node_qnode_keys + if len(node_qnode_keys) == 0: + nodes_to_remove.add(key) + for key in skipped_qnode_keys: + del node_keys_to_remove[key] + for key in nodes_to_remove: + del kg.nodes[key] + for key, edge in kg.edges.items(): + if edge.subject in node_keys_to_remove or edge.object in node_keys_to_remove: + edges_to_remove.add(key) + self.check_kg_nodes() + + # remove edges + for key in edges_to_remove: + if edge_params.get('qedge_keys', None) is not None: + key_edge = kg.edges[key] + key_edge_qedge_keys = getattr(key_edge, 'qedge_keys', None) + if key_edge_qedge_keys is not None: + qedge_key_diff = set(key_edge_qedge_keys) - set(edge_params['qedge_keys']) + if len(qedge_key_diff) < 1: + del kg.edges[key] + else: + key_edge.qedge_keys = list(qedge_key_diff) + else: + self.response.warning( + f"The edge {key} does not have a qedge_keys property. Since a value was supplied for the qedge_keys parameter the edge was not removed.") + else: + del kg.edges[key] + except Exception: + tb = traceback.format_exc() + error_type, error, _ = sys.exc_info() + self.response.error(tb, error_code=error_type.__name__) + self.response.error("Something went wrong removing edges from the knowledge graph") + else: + self.response.info(f"Edges successfully removed: {len(edges_to_remove)}; num left: {len(kg.edges)}") + + return self.response diff --git a/code/ARAX/test/test_ARAX_filter_kg.py b/code/ARAX/test/test_ARAX_filter_kg.py index b12678f30..dcc4889e6 100644 --- a/code/ARAX/test/test_ARAX_filter_kg.py +++ b/code/ARAX/test/test_ARAX_filter_kg.py @@ -342,3 +342,125 @@ def test_tuple_bug(): if __name__ == "__main__": pytest.main(['-v']) + + +# ── Statistical Significance Qualifier tests ─────────────────────────── + +def test_significance_qualifier_filter_ordinal(): + """Verify ordinal removal: below-threshold edges removed, qualifier-less edges kept.""" + from openapi_server.models.qualifier import Qualifier + from Filter_KG.remove_edges import RemoveEdges + + response = ARAXResponse() + message = Message() + message.query_graph = QueryGraph() + message.query_graph.nodes = {"n0": QNode(ids=["A"]), "n1": QNode(ids=["B"])} + message.query_graph.edges = {"e0": QEdge(subject="n0", object="n1")} + message.knowledge_graph = KnowledgeGraph() + message.knowledge_graph.nodes = {"A": Node(), "B": Node()} + message.knowledge_graph.edges = {} + + def _make_edge(key, sig_value): + quals = [Qualifier(qualifier_type_id="biolink:statistical_significance_qualifier", + qualifier_value=sig_value)] if sig_value else None + edge = Edge(subject="A", object="B", predicate="biolink:related_to", + qualifiers=quals) + edge.qedge_keys = ["e0"] + message.knowledge_graph.edges[key] = edge + + _make_edge("e_sig", "significant") + _make_edge("e_sugg", "suggestive") + _make_edge("e_notsig","not_significant") + _make_edge("e_none", None) + + params = {"minimum_significance": "significant", "remove_connected_nodes": False} + re = RemoveEdges(response, message, params) + re.remove_edges_by_statistical_significance() + + assert "e_sig" in message.knowledge_graph.edges + assert "e_sugg" not in message.knowledge_graph.edges + assert "e_notsig" not in message.knowledge_graph.edges + assert "e_none" in message.knowledge_graph.edges + + +def test_significance_qualifier_filter_threshold_boundaries(): + """Verify boundary behavior for each possible minimum_significance value.""" + from openapi_server.models.qualifier import Qualifier + from Filter_KG.remove_edges import RemoveEdges + + def _build_message(): + response = ARAXResponse() + message = Message() + message.query_graph = QueryGraph() + message.query_graph.nodes = {"n0": QNode(ids=["A"]), "n1": QNode(ids=["B"])} + message.query_graph.edges = {"e0": QEdge(subject="n0", object="n1")} + message.knowledge_graph = KnowledgeGraph() + message.knowledge_graph.nodes = {"A": Node(), "B": Node()} + message.knowledge_graph.edges = {} + for band in ["very_strongly_significant", "strongly_significant", + "significant", "suggestive", "not_significant"]: + edge = Edge( + subject="A", object="B", predicate="biolink:related_to", + qualifiers=[Qualifier(qualifier_type_id="biolink:statistical_significance_qualifier", + qualifier_value=band)]) + edge.qedge_keys = ["e0"] + message.knowledge_graph.edges[f"e_{band}"] = edge + return response, message + + # very_strongly_significant → removes everything else + response, message = _build_message() + re = RemoveEdges(response, message, {"minimum_significance": "very_strongly_significant", + "remove_connected_nodes": False}) + re.remove_edges_by_statistical_significance() + assert "e_very_strongly_significant" in message.knowledge_graph.edges + assert len(message.knowledge_graph.edges) == 1 + + # not_significant → removes nothing (all at or above) + response, message = _build_message() + re = RemoveEdges(response, message, {"minimum_significance": "not_significant", + "remove_connected_nodes": False}) + re.remove_edges_by_statistical_significance() + assert len(message.knowledge_graph.edges) == 5 + + # suggestive → removes only not_significant + response, message = _build_message() + re = RemoveEdges(response, message, {"minimum_significance": "suggestive", + "remove_connected_nodes": False}) + re.remove_edges_by_statistical_significance() + assert "e_not_significant" not in message.knowledge_graph.edges + assert "e_suggestive" in message.knowledge_graph.edges + assert len(message.knowledge_graph.edges) == 4 + + +def test_command_definitions_includes_significance(): + """Verify the new action is registered in command_definitions.""" + fkg = ARAXFilterKG() + assert "remove_edges_by_statistical_significance" in fkg.allowable_actions + assert "remove_edges_by_statistical_significance" in fkg.command_definitions + + +@pytest.mark.slow +def test_significance_qualifier_filter_integration(): + """End-to-end: expand from retriever, filter by significance, verify results.""" + query = {"operations": {"actions": [ + "create_message", + "add_qnode(name=MONDO:0005148, key=n00)", + "add_qnode(categories=biolink:Gene, key=n01)", + "add_qedge(subject=n00, object=n01, key=e00)", + "expand(edge_key=e00, kp=infores:retriever)", + "filter_kg(action=remove_edges_by_statistical_significance, " + " minimum_significance=suggestive)", + "resultify()", + "return(message=true, store=false)" + ]}} + [response, message] = _do_arax_query(query) + assert response.status == 'OK' + # Verify no remaining edges have not_significant qualifier + for edge in message.knowledge_graph.edges.values(): + if edge.qualifiers: + for q in edge.qualifiers: + if q.qualifier_type_id == "biolink:statistical_significance_qualifier": + val = q.qualifier_value + if isinstance(val, str) and val.startswith("biolink:"): + val = val[len("biolink:"):] + assert val != "not_significant" diff --git a/code/ARAX/test/test_ARAX_ranker.py b/code/ARAX/test/test_ARAX_ranker.py index 7b87116c3..c4ffbb20e 100644 --- a/code/ARAX/test/test_ARAX_ranker.py +++ b/code/ARAX/test/test_ARAX_ranker.py @@ -960,3 +960,119 @@ def test_ARAXRanker_test23_asset378(): if __name__ == "__main__": pytest.main(['-v']) + + +# ── Statistical Significance Qualifier tests ─────────────────────────── + +def test_significance_qualifier_score_mapping(): + """Verify ordinal score mapping for all five significance bands.""" + from ARAX_ranker import _significance_band_scores + scores = [] + for band in ["very_strongly_significant", "strongly_significant", + "significant", "suggestive", "not_significant"]: + score = _significance_band_scores[band] + assert 0.0 <= score <= 1.0 + scores.append(score) + # Scores must be strictly descending (more significant = higher score) + assert scores == sorted(scores, reverse=True) + assert scores[-1] == 0.0 # not_significant → 0 + + +def test_significance_qualifier_lookup(): + """Verify _get_significance_qualifier_value finds the qualifier in edge.qualifiers.""" + from openapi_server.models.qualifier import Qualifier + ranker = ARAXRanker() + + # In edge.qualifiers (expected Retriever path) + edge_q = Edge(subject="A", object="B", predicate="biolink:related_to", + qualifiers=[Qualifier(qualifier_type_id="biolink:statistical_significance_qualifier", + qualifier_value="significant")]) + assert ranker._get_significance_qualifier_value(edge_q) == "significant" + + # biolink:-prefixed value + edge_prefixed = Edge(subject="A", object="B", predicate="biolink:related_to", + qualifiers=[Qualifier(qualifier_type_id="biolink:statistical_significance_qualifier", + qualifier_value="biolink:significant")]) + assert ranker._get_significance_qualifier_value(edge_prefixed) == "significant" + + # No qualifier at all + edge_none = Edge(subject="A", object="B", predicate="biolink:related_to") + assert ranker._get_significance_qualifier_value(edge_none) is None + + +def test_significance_qualifier_lookup_edge_cases(): + """Verify lookup handles edge cases: multiple qualifiers, unrecognized values, empty lists.""" + from openapi_server.models.qualifier import Qualifier + ranker = ARAXRanker() + + # Multiple qualifiers on same edge — only pick the significance one + edge_multi_q = Edge(subject="A", object="B", predicate="biolink:related_to", + qualifiers=[ + Qualifier(qualifier_type_id="biolink:object_direction_qualifier", + qualifier_value="increased"), + Qualifier(qualifier_type_id="biolink:statistical_significance_qualifier", + qualifier_value="suggestive"), + Qualifier(qualifier_type_id="biolink:object_aspect_qualifier", + qualifier_value="activity_or_abundance"), + ]) + assert ranker._get_significance_qualifier_value(edge_multi_q) == "suggestive" + + # Empty qualifiers list (not None) + edge_empty_q = Edge(subject="A", object="B", predicate="biolink:related_to", qualifiers=[]) + assert ranker._get_significance_qualifier_value(edge_empty_q) is None + + # Qualifier present but value is an unrecognized enum string + edge_bad = Edge(subject="A", object="B", predicate="biolink:related_to", + qualifiers=[Qualifier(qualifier_type_id="biolink:statistical_significance_qualifier", + qualifier_value="totally_significant")]) + # Lookup returns the raw value; the scorer maps unknown → 0.0 + assert ranker._get_significance_qualifier_value(edge_bad) == "totally_significant" + + +def test_significance_qualifier_additive_with_pvalue(): + """Verify the qualifier contributes additively alongside a numeric pValue attribute.""" + from openapi_server.models.attribute import Attribute + from openapi_server.models.qualifier import Qualifier + ranker = ARAXRanker() + + # Edge with both a pValue attribute AND a significance qualifier + edge = Edge(subject="A", object="B", predicate="biolink:related_to", + attributes=[Attribute(attribute_type_id="biolink:pValue", + original_attribute_name="pValue", + value="0.001")], + qualifiers=[Qualifier(qualifier_type_id="biolink:statistical_significance_qualifier", + qualifier_value="very_strongly_significant")]) + confidence = ranker.edge_attribute_score_combiner("A--biolink:related_to--B--infores:test", edge) + assert 0.0 < confidence <= 1.0 + # Should be higher than base score of 0.5 since both signals are positive + assert confidence > 0.5 + + # Edge with only pValue (no qualifier) — should still be > 0.5 but lower contribution + edge_no_qual = Edge(subject="A", object="B", predicate="biolink:related_to", + attributes=[Attribute(attribute_type_id="biolink:pValue", + original_attribute_name="pValue", + value="0.001")]) + confidence_no_qual = ranker.edge_attribute_score_combiner("A--biolink:related_to--B--infores:test", edge_no_qual) + assert confidence >= confidence_no_qual # additive qualifier can only help or be neutral + + +@pytest.mark.slow +def test_significance_qualifier_ranking_integration(): + """End-to-end: expand, rank, verify results are scored and sorted.""" + query = {"operations": {"actions": [ + "create_message", + "add_qnode(name=MONDO:0005148, key=n00)", + "add_qnode(categories=biolink:Gene, key=n01)", + "add_qedge(subject=n00, object=n01, key=e00)", + "expand(edge_key=e00, kp=infores:retriever)", + "resultify()", + "return(message=true, store=false)" + ]}} + araxq = ARAXQuery() + araxq.query(query) + response = araxq.response + assert response.status == 'OK' + message = response.envelope.message + assert len(message.results) > 0 + scores = [r.analyses[0].score for r in message.results] + assert scores == sorted(scores, reverse=True)