File size: 7,971 Bytes
9f3852c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
"""The modality surface: the UI radio, the machine endpoint, and the refusal between them.

Two facts drive this file, and they pull in opposite directions.

**A closed-choice widget can never exercise the refusal.** `bs_modality` is a `gr.Radio`, so
gradio validates the submitted value against the radio's own `choices` and returns a generic
``Value: 'fusion' … is not in the list of choices`` before `variant_by_subtype` is called. That
is true for ANY choice set β€” widening the radio to three options does not make the fourth value
reachable, it just moves the wall. So the `unsupported_modality` refusal cannot be owned by a
click-through, ever, and these tests own it instead. That is not a stopgap; it is where the case
belongs, because the only surface that can carry an unrecognized modality is the `gr.api`
machine endpoint the orchestrator calls with a free-form string.

**But the radio still has to match the tool.** For the whole life of the SV modality (ADR-0008)
the radio's literal was `["mutation", "cnv"]` while `variant_by_subtype` accepted `sv` and
`deploy/orchestrator_registration.yaml` published it β€” the UI offering a narrower contract than
the machine endpoint, which is the same family of failure as the modality fallback this refusal
was written to prevent: the surface answering a different question than the one on the label.
`test_ui_modality_choices_equal_the_tools_closed_set` is the regression that would have caught
it on the day `sv` landed, and will catch the fourth modality too.
"""

from __future__ import annotations

import json

import pytest

import gradio_ui
from src.tools.variant_by_subtype import SUPPORTED_MODALITIES, variant_by_subtype


class _Request:
    """Minimal stand-in for `gr.Request` β€” only `.headers.get` is used."""

    def __init__(self, headers=None):
        self.headers = headers or {}


@pytest.fixture(autouse=True)
def _open_gate(monkeypatch, tmp_path):
    """These tests are about the modality contract, not the auth gate (test_access owns that)."""
    monkeypatch.delenv("ACCESS_CONTROL", raising=False)
    monkeypatch.setenv("LOG_SINK", "local")
    monkeypatch.setenv("LOG_SINK_LOCAL_DIR", str(tmp_path / "run_logs"))


def _modality_radio():
    """The Variant Γ— subtype tab's modality radio, found in the built Blocks graph."""
    radios = [
        block
        for block in gradio_ui.demo.blocks.values()
        if block.__class__.__name__ == "Radio" and getattr(block, "label", None) == "Modality"
    ]
    assert len(radios) == 1, f"expected exactly one Modality radio, found {len(radios)}"
    return radios[0]


# --- the UI surface must equal the tool's closed set ----------------------------------

def test_ui_modality_choices_equal_the_tools_closed_set():
    """The radio is DERIVED from `SUPPORTED_MODALITIES`; this locks that it stays derived.

    Fails if a modality is added to the tool and not the UI (the 2026-08-05 `sv` gap), and
    fails if a value is added to the radio that the tool would refuse β€” which would turn an
    ordinary user click into an `unsupported_modality` error.
    """
    # gradio normalizes `choices` to (label, value) pairs.
    offered = {c[1] if isinstance(c, (tuple, list)) else c for c in _modality_radio().choices}
    assert offered == set(SUPPORTED_MODALITIES)


def test_sv_is_offered_and_mutation_stays_the_default():
    radio = _modality_radio()
    offered = {c[1] if isinstance(c, (tuple, list)) else c for c in radio.choices}
    assert "sv" in offered, "ADR-0008's SV modality must be reachable from the UI"
    assert radio.value == "mutation"


# --- the refusal, on the only surface that can reach it -------------------------------

@pytest.mark.parametrize("bad", ["fusion", "mutaton", "expression", "SV_STATUS"])
def test_machine_endpoint_refuses_an_unsupported_modality(patched_cbio, bad):
    """The `gr.api` endpoint is where a free-form modality string actually arrives.

    The radio cannot produce one; the orchestrator can, and did β€” this is the path that used
    to answer a fusion question with a mutation association and `join_available: true`.
    """
    body = json.loads(
        gradio_ui.variant_by_subtype(
            genes="KRAS", source="cbioportal:paad_tcga", modality=bad, request=_Request()
        )
    )
    assert body["join_available"] is False
    assert body["route"] == "unsupported_modality"
    assert set(body["supported_modalities"]) == set(SUPPORTED_MODALITIES)
    assert "genes" not in body, "no association may be computed for a refused modality"


def test_machine_endpoint_keeps_blank_meaning_unspecified(patched_cbio):
    """Blank is the ordinary case, NOT an unknown value β€” an untouched field sends "".

    Guarded here as well as at the tool, because the normalization that makes it work
    (`(modality or "mutation").strip()`) lives in `gradio_ui`, and a refusal here would break
    every orchestrator call that leaves the field alone.
    """
    for blank in ("", "   ", "\t"):
        body = json.loads(
            gradio_ui.variant_by_subtype(
                genes="KRAS", source="cbioportal:paad_tcga", modality=blank, request=_Request()
            )
        )
        assert body.get("route") != "unsupported_modality", f"blank {blank!r} means 'unspecified'"
        assert body["modality"] == "mutation"


# --- a refusal must be VISIBLE, not just present in the JSON --------------------------
#
# Both routes below became clickable when `sv` joined the radio: 5 of the 7 curated cohorts
# publish no SV at all. Before this, neither had a caution branch β€” they fell through to
# `caveats`, which a refusal does not carry, so the box stayed `visible=False` and the reader
# saw an empty plot and nothing else. That is the ADR-0004 invisible-denial failure in a second
# place: an answer that refuses in the JSON and looks merely empty on screen.

def test_unavailable_modality_is_visible_in_the_caution_box(patched_cbio):
    result = variant_by_subtype(["KRAS"], "cbioportal:paad_tcga", modality="sv")
    assert result["route"] == "unavailable_modality"  # TCGA publishes no SV

    caution = gradio_ui._subtype_caution_md(result)
    assert caution["visible"] is True
    assert "no sv data" in caution["value"].lower()


def test_unsupported_modality_is_visible_in_the_caution_box(patched_cbio):
    result = variant_by_subtype(["KRAS"], "cbioportal:paad_tcga", modality="fusion")
    assert result["route"] == "unsupported_modality"

    caution = gradio_ui._subtype_caution_md(result)
    assert caution["visible"] is True
    assert "unsupported modality" in caution["value"].lower()
    for modality in SUPPORTED_MODALITIES:
        assert modality in caution["value"]


# --- the SV join is a real capability, not a decorative choice -------------------------

def test_sv_join_returns_counts_and_the_sparsity_caveat():
    # No `patched_cbio`: this reads the COMMITTED `ccle_broad_2019` artifact, the only curated
    # cohort carrying both SV and a subtype-shaped clinical attribute. Still hermetic β€” since
    # ADR-0005 C4 the request path reads artifacts and never calls the API.
    """Adding the choice is only defensible if `sv` can actually answer.

    It can: on a cohort carrying both SV and a named subtype attribute, the join runs and
    returns contingency counts. Most genes fall below `MIN_ALTERED_FOR_TEST` and report
    `testable: false` β€” that is the honest form of a rare event, and the SV caveat says so, so
    a reader cannot mistake sparse counts for an absence of association.
    """
    result = variant_by_subtype(
        ["KRAS", "NRG1"],
        "cbioportal:ccle_broad_2019",
        subtype_attribute="SUBTYPE",
        modality="sv",
    )
    assert result["join_available"] is True
    assert result["modality"] == "sv"
    assert result["genes"], "an SV join must return per-gene blocks, not an empty answer"
    assert any("Structural variants are RARE" in c for c in result["caveats"])