From 1050ee1912d7ee3c6e1ffcb81fc462414d7de020 Mon Sep 17 00:00:00 2001 From: SkyeAv Date: Thu, 30 Jul 2026 10:25:26 -0700 Subject: [PATCH 1/5] feat: add shared statistical_significance_qualifier helpers New shepherd_utils/statistical_significance_qualifier.py module with: - SIGNIFICANCE_BAND_SCORES / SIGNIFICANCE_ORDINAL tables (biolink#1766) - SIGNIFICANCE_SOURCE_WEIGHT (0.5, conservative, matches RTX) - get_statistical_significance(edge): qualifiers-only lookup Shared by aragorn_score, arax_rank, and the filter worker. Tests: 7/7 passing. Refs: shepherd#134 --- .gitignore | 3 + .../statistical_significance_qualifier.py | 56 ++++++++++++ ...test_statistical_significance_qualifier.py | 86 +++++++++++++++++++ 3 files changed, 145 insertions(+) create mode 100644 shepherd_utils/statistical_significance_qualifier.py create mode 100644 tests/unit/test_statistical_significance_qualifier.py diff --git a/.gitignore b/.gitignore index 4fb74c6..e839994 100644 --- a/.gitignore +++ b/.gitignore @@ -176,3 +176,6 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +# Local planning notes (not part of the shipped code) +PLAN.md diff --git a/shepherd_utils/statistical_significance_qualifier.py b/shepherd_utils/statistical_significance_qualifier.py new file mode 100644 index 0000000..59fe4c2 --- /dev/null +++ b/shepherd_utils/statistical_significance_qualifier.py @@ -0,0 +1,56 @@ +"""Shared helpers for biolink:statistical_significance_qualifier (shepherd#134). + +The qualifier (enum StatisticalSignificanceQualifierEnum) is_a statement_qualifier, +a descendant of `qualifier` in biolink-model, so BMT/Retriever route it into TRAPI +edge.qualifiers[]. get_statistical_significance() therefore reads edge['qualifiers'] +ONLY. (ARAX is likewise qualifier-only as of RTXteam/RTX#2859 — its defensive +edge.attributes lookup was removed; add a fallback here only if a KP is found to +send the qualifier as an attribute.) Shared by aragorn_score + arax_rank (ranking) +and the filter_edges_by_statistical_significance worker (filtering). +""" + +from typing import Any, Dict, Optional + +SIGNIFICANCE_QUALIFIER_TYPE_ID = "biolink:statistical_significance_qualifier" + +# Conservative ranking scores per band. TODO: revisit once all KGs populate the +# qualifier (rollout asymmetry: qualifier-bearing edges scored against edges that +# lack it entirely). Mirrors the RTX ARAX_ranker change (RTXteam/RTX#2858). +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, +} + +# Ordinal ranking for filtering (remove edges below a threshold). +SIGNIFICANCE_ORDINAL: Dict[str, int] = { + "very_strongly_significant": 4, + "strongly_significant": 3, + "significant": 2, + "suggestive": 1, + "not_significant": 0, +} + +# Source-agnostic trust weight applied to the band score in ranking (conservative; +# matches RTX trust=0.5). NOT routed through aragorn's per-source get_source_weight. +SIGNIFICANCE_SOURCE_WEIGHT: float = 0.5 + + +def _strip_biolink(value: Any) -> Optional[str]: + if isinstance(value, str) and value.startswith("biolink:"): + return value[len("biolink:"):] + return value + + +def get_statistical_significance(edge: Dict[str, Any]) -> Optional[str]: + """Return the bare significance band for a dict-based TRAPI edge, or None. + + Reads edge['qualifiers'] only (it is a biolink qualifier; BMT/Retriever route it + there). Strips any biolink: prefix from the value. + """ + for q in edge.get("qualifiers") or []: + if q.get("qualifier_type_id") == SIGNIFICANCE_QUALIFIER_TYPE_ID: + return _strip_biolink(q.get("qualifier_value")) + return None diff --git a/tests/unit/test_statistical_significance_qualifier.py b/tests/unit/test_statistical_significance_qualifier.py new file mode 100644 index 0000000..eb9732d --- /dev/null +++ b/tests/unit/test_statistical_significance_qualifier.py @@ -0,0 +1,86 @@ +"""Tests for shepherd_utils.statistical_significance_qualifier (shepherd#134).""" + +from shepherd_utils.statistical_significance_qualifier import ( + SIGNIFICANCE_BAND_SCORES, + SIGNIFICANCE_ORDINAL, + get_statistical_significance, +) + + +def test_band_scores_descending(): + bands = [ + "very_strongly_significant", + "strongly_significant", + "significant", + "suggestive", + "not_significant", + ] + scores = [SIGNIFICANCE_BAND_SCORES[b] for b in bands] + assert scores == sorted(scores, reverse=True) and scores[-1] == 0.0 + + +def test_ordinal_matches_band_order(): + assert SIGNIFICANCE_ORDINAL["very_strongly_significant"] == 4 + assert SIGNIFICANCE_ORDINAL["not_significant"] == 0 + + +def test_lookup_in_qualifiers(): + edge = { + "qualifiers": [ + { + "qualifier_type_id": "biolink:statistical_significance_qualifier", + "qualifier_value": "significant", + } + ] + } + assert get_statistical_significance(edge) == "significant" + + +def test_attributes_are_ignored(): + # Qualifiers-only by design: an attributes-only qualifier is NOT read + # (matches ARAX, qualifier-only as of RTX#2859). + edge = { + "attributes": [ + { + "attribute_type_id": "biolink:statistical_significance_qualifier", + "value": "suggestive", + } + ] + } + assert get_statistical_significance(edge) is None + + +def test_lookup_strips_biolink_prefix(): + edge = { + "qualifiers": [ + { + "qualifier_type_id": "biolink:statistical_significance_qualifier", + "qualifier_value": "biolink:significant", + } + ] + } + assert get_statistical_significance(edge) == "significant" + + +def test_only_qualifiers_read(): + # The band comes from edge['qualifiers']; attributes are not consulted. + edge = { + "qualifiers": [ + { + "qualifier_type_id": "biolink:statistical_significance_qualifier", + "qualifier_value": "significant", + } + ], + "attributes": [ + { + "attribute_type_id": "biolink:statistical_significance_qualifier", + "value": "not_significant", + } + ], + } + assert get_statistical_significance(edge) == "significant" + + +def test_lookup_none_when_absent(): + assert get_statistical_significance({"attributes": []}) is None + assert get_statistical_significance({}) is None From 9ca380b357db36b7b0d4bb24f2519a0df654afa4 Mon Sep 17 00:00:00 2001 From: SkyeAv Date: Thu, 30 Jul 2026 10:26:48 -0700 Subject: [PATCH 2/5] feat: score statistical_significance_qualifier in shared aragorn/BTE ranker aragorn_score/worker.py (shared Aragorn + BTE ranker): - Extract qualifier via get_statistical_significance() in get_edge_values() - Add as scored property: band_score x 0.5 trust -> admittance feed - not_significant / qualifier-less edges unchanged (no penalty) Tests: 35/35 passing (4 new). Refs: shepherd#134 --- .../unit/aragorn/test_aragorn_score_ranker.py | 82 +++++++++++++++++++ workers/aragorn_score/worker.py | 21 +++++ 2 files changed, 103 insertions(+) diff --git a/tests/unit/aragorn/test_aragorn_score_ranker.py b/tests/unit/aragorn/test_aragorn_score_ranker.py index 5bb28fa..625da45 100644 --- a/tests/unit/aragorn/test_aragorn_score_ranker.py +++ b/tests/unit/aragorn/test_aragorn_score_ranker.py @@ -673,3 +673,85 @@ def test_score_jaccard_like_returns_score_over_one_minus_score(): assert scored["analyses"][0]["score"] == pytest.approx( raw_score / (1 - raw_score) ) + + +# --- statistical significance qualifier (shepherd#134) -------------------- + + +def test_get_edge_values_extracts_significance_qualifier(): + """A qualifier-bearing edge gets a statistical_significance property.""" + edge = { + "subject": "A", + "object": "B", + "predicate": "biolink:related_to", + "sources": [ + {"resource_id": "infores:test", "resource_role": "primary_knowledge_source"} + ], + "qualifiers": [ + { + "qualifier_type_id": "biolink:statistical_significance_qualifier", + "qualifier_value": "very_strongly_significant", + } + ], + } + r = Ranker(_make_msg_with_edge(edge), logger) + vals = r.get_edge_values("e1") + assert "statistical_significance" in vals["infores:test"] + prop = vals["infores:test"]["statistical_significance"] + assert prop["value"] == "very_strongly_significant" + assert prop["weight"] > 0 + assert prop["weight"] == pytest.approx(0.70 * 0.5) + + +def test_get_edge_values_significance_strips_biolink_prefix(): + """biolink:-prefixed qualifier values are stripped.""" + edge = { + "subject": "A", + "object": "B", + "sources": [ + {"resource_id": "infores:test", "resource_role": "primary_knowledge_source"} + ], + "qualifiers": [ + { + "qualifier_type_id": "biolink:statistical_significance_qualifier", + "qualifier_value": "biolink:significant", + } + ], + } + r = Ranker(_make_msg_with_edge(edge), logger) + vals = r.get_edge_values("e1") + assert vals["infores:test"]["statistical_significance"]["value"] == "significant" + + +def test_not_significant_contributes_nothing(): + """Band score 0 -> property omitted -> no admittance contribution (no penalty).""" + edge = { + "subject": "A", + "object": "B", + "sources": [ + {"resource_id": "infores:test", "resource_role": "primary_knowledge_source"} + ], + "qualifiers": [ + { + "qualifier_type_id": "biolink:statistical_significance_qualifier", + "qualifier_value": "not_significant", + } + ], + } + r = Ranker(_make_msg_with_edge(edge), logger) + vals = r.get_edge_values("e1") + assert "statistical_significance" not in vals["infores:test"] + + +def test_qualifierless_edge_has_no_significance_property(): + """Edges without the qualifier get no statistical_significance property.""" + edge = { + "subject": "A", + "object": "B", + "sources": [ + {"resource_id": "infores:test", "resource_role": "primary_knowledge_source"} + ], + } + r = Ranker(_make_msg_with_edge(edge), logger) + vals = r.get_edge_values("e1") + assert "statistical_significance" not in vals["infores:test"] diff --git a/workers/aragorn_score/worker.py b/workers/aragorn_score/worker.py index c4f7c77..e8c83ef 100644 --- a/workers/aragorn_score/worker.py +++ b/workers/aragorn_score/worker.py @@ -17,6 +17,11 @@ from shepherd_utils.otel import setup_tracer from shepherd_utils.process_pool import ProcessPoolManager from shepherd_utils.shared import get_tasks, run_task_lifecycle +from shepherd_utils.statistical_significance_qualifier import ( + SIGNIFICANCE_BAND_SCORES, + SIGNIFICANCE_SOURCE_WEIGHT, + get_statistical_significance, +) # Queue name STREAM = "aragorn.score" @@ -890,6 +895,7 @@ def get_edge_values(self, edge_id): "literature_coocurrence": None, "p_value": None, "affinity": None, + "statistical_significance": None, } # Look through attributes and @@ -998,6 +1004,10 @@ def get_edge_values(self, edge_id): if orig_attr_name == "biolink:tmkp_confidence_score": usable_edge_attr["confidence_score"] = attribute.get("value", 0) + # Qualifier lives in edge["qualifiers"] (BMT/Retriever path); the + # attribute loop above won't see it. + usable_edge_attr["statistical_significance"] = get_statistical_significance(edge) + # At this point we have all of the information extracted from the edge # We have have looked through all attributes and updated # usable_edge_attr. Now we can construct the edge values using these @@ -1091,6 +1101,17 @@ def get_edge_values(self, edge_id): "weight": property_w * source_w, } + if usable_edge_attr["statistical_significance"] is not None: + band = usable_edge_attr["statistical_significance"] + property_w = SIGNIFICANCE_BAND_SCORES.get(band, 0.0) + if property_w > 0: + this_edge_vals[edge_source]["statistical_significance"] = { + "value": band, + "property_weight": property_w, + "source_weight": SIGNIFICANCE_SOURCE_WEIGHT, + "weight": property_w * SIGNIFICANCE_SOURCE_WEIGHT, + } + # Cache it self.edge_values[edge_id] = this_edge_vals return this_edge_vals From 127242568e0f338cf38eb970d81941e35ad34916 Mon Sep 17 00:00:00 2001 From: SkyeAv Date: Thu, 30 Jul 2026 10:27:52 -0700 Subject: [PATCH 3/5] feat: add statistical significance qualifier scoring to arax_rank ranker arax_rank/ranker.py (dict-based port of RTX ARAX_ranker.py): - Categorical bypass: qualifier looked up separately from attributes - Additive score: band_score x SIGNIFICANCE_SOURCE_WEIGHT (0.5) - Injected at method level (works even for attribute-less edges) - Mirrors RTX post-refactor: standalone trust weight, not in known_attributes_to_trust Tests: 5/5 passing. Refs: shepherd#134 --- tests/unit/test_arax_rank_ranker.py | 134 ++++++++++++++++++++++++++++ workers/arax_rank/ranker.py | 17 ++++ 2 files changed, 151 insertions(+) create mode 100644 tests/unit/test_arax_rank_ranker.py diff --git a/tests/unit/test_arax_rank_ranker.py b/tests/unit/test_arax_rank_ranker.py new file mode 100644 index 0000000..f2bca4d --- /dev/null +++ b/tests/unit/test_arax_rank_ranker.py @@ -0,0 +1,134 @@ +"""Tests for statistical significance qualifier scoring in arax_rank (shepherd#134).""" + +import logging + +from workers.arax_rank.ranker import ARAXRanker + +logger = logging.getLogger(__name__) + + +def _edge(**kw): + return {"subject": "A", "object": "B", "predicate": "biolink:related_to", **kw} + + +def test_significance_additive_boost(): + """A qualifier-bearing edge scores >= the same edge without the qualifier.""" + ranker = ARAXRanker(logger) + base = ranker._calculate_edge_confidence( + "infores:test--A--B", + _edge( + attributes=[ + { + "attribute_type_id": "biolink:pValue", + "original_attribute_name": "pValue", + "value": "0.001", + } + ] + ), + ) + boosted = ranker._calculate_edge_confidence( + "infores:test--A--B", + _edge( + attributes=[ + { + "attribute_type_id": "biolink:pValue", + "original_attribute_name": "pValue", + "value": "0.001", + } + ], + qualifiers=[ + { + "qualifier_type_id": "biolink:statistical_significance_qualifier", + "qualifier_value": "very_strongly_significant", + } + ], + ), + ) + assert boosted >= base # additive qualifier can only help or be neutral + + +def test_significance_score_mapping(): + """Each band maps to band_score * 0.5 trust, appended to the score list.""" + ranker = ARAXRanker(logger) + # Edge with no attributes and no qualifier -> base only + no_qual = ranker._calculate_edge_confidence("infores:test--A--B", _edge()) + # Edge with qualifier only (no attributes) -> base + qualifier boost + with_qual = ranker._calculate_edge_confidence( + "infores:test--A--B", + _edge( + qualifiers=[ + { + "qualifier_type_id": "biolink:statistical_significance_qualifier", + "qualifier_value": "significant", + } + ] + ), + ) + # significant = 0.40 * 0.5 = 0.20 additive boost + assert with_qual > no_qual + + +def test_not_significant_adds_nothing(): + """not_significant (score 0.0) adds no boost.""" + ranker = ARAXRanker(logger) + no_qual = ranker._calculate_edge_confidence("infores:test--A--B", _edge()) + not_sig = ranker._calculate_edge_confidence( + "infores:test--A--B", + _edge( + qualifiers=[ + { + "qualifier_type_id": "biolink:statistical_significance_qualifier", + "qualifier_value": "not_significant", + } + ] + ), + ) + assert not_sig == no_qual + + +def test_biolink_prefix_stripped(): + """biolink:-prefixed qualifier values are handled.""" + ranker = ARAXRanker(logger) + bare = ranker._calculate_edge_confidence( + "infores:test--A--B", + _edge( + qualifiers=[ + { + "qualifier_type_id": "biolink:statistical_significance_qualifier", + "qualifier_value": "strongly_significant", + } + ] + ), + ) + prefixed = ranker._calculate_edge_confidence( + "infores:test--A--B", + _edge( + qualifiers=[ + { + "qualifier_type_id": "biolink:statistical_significance_qualifier", + "qualifier_value": "biolink:strongly_significant", + } + ] + ), + ) + assert bare == prefixed + + +def test_qualifier_works_without_attributes(): + """Qualifier scoring works even for edges with no attributes at all.""" + ranker = ARAXRanker(logger) + # No attributes, no qualifier -> base only (0.5 for infores) + base_only = ranker._calculate_edge_confidence("infores:test--A--B", _edge()) + # No attributes, but has qualifier -> base + boost + with_qual = ranker._calculate_edge_confidence( + "infores:test--A--B", + _edge( + qualifiers=[ + { + "qualifier_type_id": "biolink:statistical_significance_qualifier", + "qualifier_value": "very_strongly_significant", + } + ] + ), + ) + assert with_qual > base_only diff --git a/workers/arax_rank/ranker.py b/workers/arax_rank/ranker.py index 3be4663..0042b16 100644 --- a/workers/arax_rank/ranker.py +++ b/workers/arax_rank/ranker.py @@ -24,6 +24,12 @@ import numpy.typing as npt import scipy.stats +from shepherd_utils.statistical_significance_qualifier import ( + SIGNIFICANCE_BAND_SCORES, + SIGNIFICANCE_SOURCE_WEIGHT, + get_statistical_significance, +) + # Default confidence for manual agent edges (matches ARAX_ranker.py line 24) EDGE_CONFIDENCE_MANUAL_AGENT = 0.90 @@ -294,6 +300,17 @@ def _calculate_edge_confidence(self, edge_key: str, edge: Dict) -> float: if normalized_score > 0: edge_attribute_score_list.append(normalized_score) + # Statistical significance qualifier is carried in edge["qualifiers"] + # (not attributes). Looked up separately (categorical bypass) so the + # enum string never hits the numeric attribute normalizer. Mirrors RTX + # _get_significance_qualifier_value + _significance_trust_weight + # (RTXteam/RTX#2859). + sig_value = get_statistical_significance(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_SOURCE_WEIGHT) + # If no attributes scored, return base score (ARAX_ranker.py lines 379-384) if len(edge_attribute_score_list) == 0: return base From ab9775998f312fea3aad32269bc90988a7d2e376 Mon Sep 17 00:00:00 2001 From: SkyeAv Date: Fri, 7 Aug 2026 12:33:35 -0700 Subject: [PATCH 4/5] chore: drop filter worker reference from significance qualifier helpers The Shepherd-side edge filter was removed per shepherd#136 (filtering belongs in Gandalf). Update the shared helper module so it no longer references the filter_edges_by_statistical_significance worker: - module docstring: shared by aragorn_score + arax_rank (ranking) only - SIGNIFICANCE_ORDINAL comment: describe the ordinal scale neutrally (table kept as part of the qualifier vocabulary) Refs: shepherd#136 --- shepherd_utils/statistical_significance_qualifier.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/shepherd_utils/statistical_significance_qualifier.py b/shepherd_utils/statistical_significance_qualifier.py index 59fe4c2..61128cf 100644 --- a/shepherd_utils/statistical_significance_qualifier.py +++ b/shepherd_utils/statistical_significance_qualifier.py @@ -5,8 +5,7 @@ edge.qualifiers[]. get_statistical_significance() therefore reads edge['qualifiers'] ONLY. (ARAX is likewise qualifier-only as of RTXteam/RTX#2859 — its defensive edge.attributes lookup was removed; add a fallback here only if a KP is found to -send the qualifier as an attribute.) Shared by aragorn_score + arax_rank (ranking) -and the filter_edges_by_statistical_significance worker (filtering). +send the qualifier as an attribute.) Shared by aragorn_score + arax_rank (ranking). """ from typing import Any, Dict, Optional @@ -24,7 +23,7 @@ "not_significant": 0.0, } -# Ordinal ranking for filtering (remove edges below a threshold). +# Ordinal scale of the significance bands (higher = more significant). SIGNIFICANCE_ORDINAL: Dict[str, int] = { "very_strongly_significant": 4, "strongly_significant": 3, From af8db3e920ce5b566f902fda7413dd1bf8ec35f1 Mon Sep 17 00:00:00 2001 From: SkyeAv Date: Fri, 7 Aug 2026 12:41:10 -0700 Subject: [PATCH 5/5] refactor: remove unused SIGNIFICANCE_ORDINAL filter table per #136 The ordinal table existed solely for the filter_edges_by_statistical_significance feature removed per #136 (filtering belongs in Gandalf, not Shepherd). Drop the now-dead constant and its test assertions; ranking/scoring only ever used SIGNIFICANCE_BAND_SCORES and SIGNIFICANCE_SOURCE_WEIGHT. Refs: shepherd#136 --- shepherd_utils/statistical_significance_qualifier.py | 11 +---------- tests/unit/test_statistical_significance_qualifier.py | 6 ------ 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/shepherd_utils/statistical_significance_qualifier.py b/shepherd_utils/statistical_significance_qualifier.py index 61128cf..ff9b350 100644 --- a/shepherd_utils/statistical_significance_qualifier.py +++ b/shepherd_utils/statistical_significance_qualifier.py @@ -23,15 +23,6 @@ "not_significant": 0.0, } -# Ordinal scale of the significance bands (higher = more significant). -SIGNIFICANCE_ORDINAL: Dict[str, int] = { - "very_strongly_significant": 4, - "strongly_significant": 3, - "significant": 2, - "suggestive": 1, - "not_significant": 0, -} - # Source-agnostic trust weight applied to the band score in ranking (conservative; # matches RTX trust=0.5). NOT routed through aragorn's per-source get_source_weight. SIGNIFICANCE_SOURCE_WEIGHT: float = 0.5 @@ -39,7 +30,7 @@ def _strip_biolink(value: Any) -> Optional[str]: if isinstance(value, str) and value.startswith("biolink:"): - return value[len("biolink:"):] + return value[len("biolink:") :] return value diff --git a/tests/unit/test_statistical_significance_qualifier.py b/tests/unit/test_statistical_significance_qualifier.py index eb9732d..3ac5cfc 100644 --- a/tests/unit/test_statistical_significance_qualifier.py +++ b/tests/unit/test_statistical_significance_qualifier.py @@ -2,7 +2,6 @@ from shepherd_utils.statistical_significance_qualifier import ( SIGNIFICANCE_BAND_SCORES, - SIGNIFICANCE_ORDINAL, get_statistical_significance, ) @@ -19,11 +18,6 @@ def test_band_scores_descending(): assert scores == sorted(scores, reverse=True) and scores[-1] == 0.0 -def test_ordinal_matches_band_order(): - assert SIGNIFICANCE_ORDINAL["very_strongly_significant"] == 4 - assert SIGNIFICANCE_ORDINAL["not_significant"] == 0 - - def test_lookup_in_qualifiers(): edge = { "qualifiers": [