avoigt1121 Claude Opus 5 commited on
Commit
2f6f12d
Β·
1 Parent(s): 12b8a32

fix(ui): make the raw-result accordion legible without touching the payload

Browse files

A full-panel `pdac_msk_2024` query rendered a ~300,000-pixel page: 21,187 lines
of JSON in an accordion that grows with its content. The payload is not the
problem and is deliberately NOT trimmed β€” `samples_profiled` is the ID set the
orchestrator derives wild-type from (`per_sample_contract`), and `per_sample` /
`provenance` are the per-sample answer. Dropping either is the bug that reported
every gene as "no variation in alteration status".

So the split is display-side only. The machine endpoints still call `_as_json`
and emit the payload byte for byte (verified live over `gradio_client`: 3 x 2,336
IDs, still lists, no display key). The UI gets three tiers over the same object:

- a summary table, gene x modality, each cell carrying the percentage AND the
fraction it came from. Absent cells NAME the absence ("not on DNA panel",
"not in cohort", "not curated") β€” never blank, never padded to 0%, and NRG1
still shows its 6 SV events while its mutation/CNV cells stay refused.
- `_ui_json`: the same object with bulk scalar collections replaced by a counted
sentinel of a different shape, so an abridged block cannot be misread as data.
21,187 -> 1,658 lines. Small payloads (denial, refusal, error) pass through
verbatim, so the denial-visibility guarantee is untouched.
- a download of the COMPLETE payload, so nothing is withheld, only deferred.
Grounded results only β€” a BYOD upload is never written to disk (ADR-0003).

Height is now capped in CSS, not by `gr.Code(max_lines=...)`: gradio 6.18
ignores that here β€” measured in the browser, the box still rendered 23,323px
tall with it set. Measured after: page 2,818px with the accordion OPEN, and it
no longer tracks cohort size at all.

The clearing frame blanks the two new slots as it does the charts β€” a stale
download link is the worst version of that bug, since the file leaves the page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

gradio_ui.py CHANGED
@@ -29,6 +29,7 @@ from __future__ import annotations
29
  import json
30
  import os
31
  import sys
 
32
  import time
33
  from pathlib import Path
34
 
@@ -114,6 +115,125 @@ def _as_json(result) -> str:
114
  return json.dumps(result, indent=2, default=str)
115
 
116
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  # --------------------------------------------------------------------------- #
118
  # Plots
119
  # --------------------------------------------------------------------------- #
@@ -215,6 +335,83 @@ def _frequency_plots(result):
215
  return updates
216
 
217
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  def _subtype_frame(result) -> pd.DataFrame | None:
219
  """Tidy (gene, subtype, % altered) frame from a `variant_by_subtype` result."""
220
  if not isinstance(result, dict) or not result.get("join_available"):
@@ -672,6 +869,11 @@ def _ui_variant_status(source, genes, profile: gr.OAuthProfile | None = None):
672
  warning (found on prod, 2026-08-05). Clearing first means the worst intermediate state a
673
  reader can catch is an EMPTY chart, which claims nothing, rather than a stale one, which
674
  claims something false. The cost is one extra frame; the alternative is a wrong answer.
 
 
 
 
 
675
  """
676
  username = _username(profile)
677
  src = registered_source(source)
@@ -679,12 +881,30 @@ def _ui_variant_status(source, genes, profile: gr.OAuthProfile | None = None):
679
  if not allowed:
680
  record_run("variant_status", username=username, source=src, status="denied")
681
  _, js = _denied_json(denial)
682
- yield (_denial_caution_md(denial), *_CLEAR_PLOTS, js)
 
 
 
 
 
 
683
  return
684
- yield (gr.update(), *_CLEAR_PLOTS, gr.update())
 
 
 
 
 
685
  result = _safe_result(lambda: _query_variant_status(list(genes) or list(PANEL), source))
686
  record_run("variant_status", username=username, source=src, result=result)
687
- yield (_status_caution_md(result), *_frequency_plots(result), _as_json(result))
 
 
 
 
 
 
 
688
 
689
 
690
  def _ui_variant_by_subtype(
@@ -816,7 +1036,22 @@ def _byod_caution_md(result):
816
  # --------------------------------------------------------------------------- #
817
  # App
818
  # --------------------------------------------------------------------------- #
819
- with gr.Blocks(title="PDAC Genomics Agent") as demo:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
820
  gr.Markdown(
821
  "# PDAC Genomics Agent\n"
822
  "Somatic **mutation**, **copy-number** and **structural-variant (fusion)** status over a "
@@ -855,16 +1090,44 @@ with gr.Blocks(title="PDAC Genomics Agent") as demo:
855
  )
856
  vs_button = gr.Button("Query variant status", variant="primary")
857
  vs_caution = gr.Markdown(visible=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
858
  # One chart per modality β€” see `_frequency_plots`. Never a single stacked chart: the
859
  # modalities have different denominators and overlapping membership, so a stack is not
860
  # a quantity. Each starts hidden and is shown only when the answer contains it.
861
  vs_plots = [gr.BarPlot(visible=False) for _ in _MODALITY_ORDER]
862
- with gr.Accordion("Raw result (JSON)", open=False):
863
- vs_json = gr.Code(label="Result", language="json")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
864
  vs_button.click(
865
  _ui_variant_status,
866
  inputs=[vs_source, vs_genes],
867
- outputs=[vs_caution, *vs_plots, vs_json],
868
  api_name=False,
869
  )
870
 
 
29
  import json
30
  import os
31
  import sys
32
+ import tempfile
33
  import time
34
  from pathlib import Path
35
 
 
115
  return json.dumps(result, indent=2, default=str)
116
 
117
 
118
+ # --------------------------------------------------------------------------- #
119
+ # Result presentation β€” summary first, abridged JSON, full payload on request
120
+ # --------------------------------------------------------------------------- #
121
+ # A full-panel `pdac_msk_2024` answer serializes to 21,187 lines / ~800 kB, which rendered the
122
+ # "Raw result (JSON)" accordion as a ~300,000-pixel page: opening it lost the reader the charts,
123
+ # the caution box and the scrollbar in one click (found in the 2026-08-05 signed-in
124
+ # click-through). The size is not an accident and is NOT fixable by trimming the payload:
125
+ #
126
+ # * `samples_profiled` is 3 x 2,336 IDs = 7,008 lines. It exists so a MACHINE caller can
127
+ # reconstruct wild-type from the sparse `per_sample` (`deploy/orchestrator_registration.yaml`,
128
+ # `per_sample_contract`). Dropping it is what previously made the orchestrator's join report
129
+ # every gene as "no variation in alteration status".
130
+ # * `per_sample` + `provenance` are another ~13,200 lines β€” KRAS alone is altered in ~90% of
131
+ # 2,336 samples. That is the actual answer, per sample, and is equally load-bearing.
132
+ #
133
+ # So the payload is right and the RENDERING was wrong. The split below is therefore strictly
134
+ # display-side: the machine endpoints keep calling `_as_json` and emit the payload byte for byte,
135
+ # while the UI gets three tiers over the same object β€” a summary table (what a non-coding
136
+ # scientist actually reads), an abridged JSON preview (structure, inspectable at a glance), and a
137
+ # download of the COMPLETE payload (nothing is withheld, only deferred).
138
+ _ABRIDGE_MIN_ITEMS = 8 # collections at or below this render in full β€” small blocks stay readable
139
+ _ABRIDGE_PREVIEW = 3 # how many entries a sentinel shows, so the shape stays visible
140
+
141
+ _ABRIDGE_NOTE = (
142
+ "This is an ABRIDGED view for on-screen reading: long sample-ID lists and per-sample maps "
143
+ "are replaced by a sentinel giving their exact size and first few entries. Nothing has been "
144
+ "removed from the answer itself β€” use β€œDownload full result (JSON)” above for the complete "
145
+ "payload, which is also exactly what the machine endpoint returns."
146
+ )
147
+
148
+
149
+ def _is_bulk(items) -> bool:
150
+ """True for a big collection of SCALARS β€” the shape worth replacing with a sentinel.
151
+
152
+ Nested collections are left alone deliberately: `genes` has 36 entries but each is a block a
153
+ reader needs, whereas `samples_profiled["mutation"]` is 2,336 interchangeable IDs. Size alone
154
+ is the wrong test; size plus flatness is the one that only ever hits bulk data.
155
+ """
156
+ items = list(items)
157
+ return len(items) > _ABRIDGE_MIN_ITEMS and not any(isinstance(v, (dict, list)) for v in items)
158
+
159
+
160
+ def _abridge(value):
161
+ """Recursively replace oversized collections with a self-describing sentinel.
162
+
163
+ The sentinel is deliberately a DIFFERENT SHAPE from what it replaces (a dict where a list
164
+ was), so an abridged block can never be mistaken for the real one β€” a truncated list that
165
+ still looks like a list is how a reader ends up believing a cohort has 8 profiled samples.
166
+ Every sentinel states the true count, which is the analytically load-bearing part: the size
167
+ of `samples_profiled[modality]` IS the denominator, and it is also carried in `n_profiled`.
168
+ """
169
+ if isinstance(value, dict):
170
+ if _is_bulk(value.values()):
171
+ return {
172
+ "__abridged__": f"{len(value)} entries β€” shown in full in the downloaded JSON",
173
+ "n_entries": len(value),
174
+ "first": dict(list(value.items())[:_ABRIDGE_PREVIEW]),
175
+ }
176
+ return {k: _abridge(v) for k, v in value.items()}
177
+ if isinstance(value, list):
178
+ if _is_bulk(value):
179
+ return {
180
+ "__abridged__": f"{len(value)} items β€” shown in full in the downloaded JSON",
181
+ "n_items": len(value),
182
+ "first": value[:_ABRIDGE_PREVIEW],
183
+ }
184
+ return [_abridge(v) for v in value]
185
+ return value
186
+
187
+
188
+ def _ui_json(result) -> str:
189
+ """The accordion's JSON: the same object, abridged for display only.
190
+
191
+ Small payloads (a denial, a refusal, a BYOD summary, a `route: orchestrator` result) pass
192
+ through untouched β€” every collection in them is under the threshold β€” so the denial-visibility
193
+ and error-shape guarantees the tests pin are unaffected.
194
+ """
195
+ if not isinstance(result, dict):
196
+ return _as_json(result)
197
+ abridged = _abridge(result)
198
+ if abridged == result: # nothing was large enough to touch β€” show it verbatim
199
+ return _as_json(result)
200
+ return _as_json({"__display__": _ABRIDGE_NOTE, **abridged})
201
+
202
+
203
+ # Full-payload downloads. Written under the system temp dir, pruned to the most recent few, and
204
+ # only ever used for GROUNDED registered-cohort results β€” a BYOD upload is never written to disk
205
+ # (ADR-0003: "never persisted"), which is why the BYOD tab has no download button.
206
+ _DOWNLOAD_DIR = Path(tempfile.gettempdir()) / "pdac-genomics-agent-results"
207
+ _DOWNLOAD_KEEP = 20
208
+
209
+
210
+ def _full_result_file(result, stem: str):
211
+ """Write the COMPLETE payload to a temp file and return a DownloadButton update.
212
+
213
+ The abridged accordion is only honest if the unabridged thing is one click away; without
214
+ this, "abridged for display" would be indistinguishable from data we quietly dropped.
215
+ """
216
+ try:
217
+ _DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
218
+ existing = sorted(_DOWNLOAD_DIR.glob("*.json"), key=lambda p: p.stat().st_mtime)
219
+ for stale in existing[: max(0, len(existing) - _DOWNLOAD_KEEP + 1)]:
220
+ stale.unlink(missing_ok=True)
221
+ handle, path = tempfile.mkstemp(prefix=f"{stem}-", suffix=".json", dir=_DOWNLOAD_DIR)
222
+ with os.fdopen(handle, "w", encoding="utf-8") as fh:
223
+ fh.write(_as_json(result))
224
+ except OSError:
225
+ # A download we cannot write is not a reason to lose the answer β€” the summary table,
226
+ # the charts and the abridged JSON all still render.
227
+ return gr.update(visible=False)
228
+ return gr.update(value=path, visible=True)
229
+
230
+
231
+ def _safe_source_stem(result) -> str:
232
+ source = result.get("source") if isinstance(result, dict) else None
233
+ text = str(source or "result").replace(":", "-")
234
+ return "".join(c if c.isalnum() or c in "-_" else "-" for c in text)[:60] or "result"
235
+
236
+
237
  # --------------------------------------------------------------------------- #
238
  # Plots
239
  # --------------------------------------------------------------------------- #
 
335
  return updates
336
 
337
 
338
+ # --------------------------------------------------------------------------- #
339
+ # Summary table β€” the top tier of the disclosure
340
+ # --------------------------------------------------------------------------- #
341
+ # One row per requested gene, one column per modality, each cell carrying the frequency AND the
342
+ # fraction it came from. It is the answer a reader wants before any of the per-sample detail,
343
+ # and it is what makes the abridged JSON acceptable: the numbers are on the page, not inside a
344
+ # collapsed accordion.
345
+ #
346
+ # An absent cell is NEVER blank and never zero. Padding every gene to three modalities is the
347
+ # specific bug this repo has already fixed twice (it reports NRG1 as 0% mutated on a cohort that
348
+ # never sequenced it), so each empty cell instead SAYS which kind of absence it is β€” and the four
349
+ # kinds are genuinely different things:
350
+ _CELL_NOT_ON_PANEL = "not on DNA panel" # cohort never interrogated the gene for this modality
351
+ _CELL_NO_MODALITY = "not in cohort" # the whole cohort lacks the modality (per-study gate)
352
+ _CELL_NOT_CURATED = "not curated" # our snapshot's gap, fixed by re-curating
353
+ _SUMMARY_GENE_COL = "gene"
354
+ _SUMMARY_NOTE_COL = "coverage"
355
+
356
+
357
+ def _summary_cell(entry: dict, modality: str, modalities_available: dict) -> str:
358
+ block = entry.get(modality)
359
+ if isinstance(block, dict) and "frequency" in block:
360
+ pct = round(block["frequency"] * 100, 1)
361
+ return f"{pct}% ({block['n_altered']} / {block['n_profiled']})"
362
+ if entry.get("curated") is False:
363
+ return _CELL_NOT_CURATED
364
+ if not modalities_available.get(modality):
365
+ return _CELL_NO_MODALITY
366
+ if entry.get("assayed") is False:
367
+ return _CELL_NOT_ON_PANEL
368
+ return _CELL_NO_MODALITY
369
+
370
+
371
+ def _coverage_note(entry: dict) -> str:
372
+ """A SHORT reason for a gated row β€” the payload keeps the full wording.
373
+
374
+ The tool's own `note` runs to three sentences, which is right in the JSON and wrong in a
375
+ table cell: one long note makes every row as tall as the longest, and 36 of them reproduce
376
+ the scrolling problem this change exists to remove. The distinctions are preserved, only
377
+ compressed β€” and the full text is one click away in the downloaded payload.
378
+ """
379
+ if entry.get("curated") is False:
380
+ return "absent from our curated snapshot β€” re-curation fixes it"
381
+ if entry.get("assayed") is False:
382
+ if isinstance(entry.get("sv"), dict):
383
+ # The modality-scoped gate (ADR-0008): off the DNA panel, but the fusion assay
384
+ # covers it, so the SV cell legitimately carries a number while the others do not.
385
+ return "off the DNA panel β€” fusion assay covers it, so SV only"
386
+ return "never sequenced in this cohort β€” not a zero"
387
+ return ""
388
+
389
+
390
+ def _summary_frame(result) -> pd.DataFrame | None:
391
+ """Gene x modality frequency table, with every absence named rather than left blank."""
392
+ genes = result.get("genes") if isinstance(result, dict) else None
393
+ if not genes:
394
+ return None
395
+ available = result.get("modalities_available") or {}
396
+ rows = []
397
+ for gene, entry in genes.items():
398
+ if not isinstance(entry, dict):
399
+ continue
400
+ row = {_SUMMARY_GENE_COL: gene}
401
+ for modality in _MODALITY_ORDER:
402
+ row[_MODALITY_LABELS[modality]] = _summary_cell(entry, modality, available)
403
+ row[_SUMMARY_NOTE_COL] = _coverage_note(entry)
404
+ rows.append(row)
405
+ return pd.DataFrame(rows) if rows else None
406
+
407
+
408
+ def _summary_table(result):
409
+ frame = _summary_frame(result)
410
+ if frame is None or frame.empty:
411
+ return gr.update(value=None, visible=False)
412
+ return gr.update(value=frame, visible=True)
413
+
414
+
415
  def _subtype_frame(result) -> pd.DataFrame | None:
416
  """Tidy (gene, subtype, % altered) frame from a `variant_by_subtype` result."""
417
  if not isinstance(result, dict) or not result.get("join_available"):
 
869
  warning (found on prod, 2026-08-05). Clearing first means the worst intermediate state a
870
  reader can catch is an EMPTY chart, which claims nothing, rather than a stale one, which
871
  claims something false. The cost is one extra frame; the alternative is a wrong answer.
872
+
873
+ Output order is (caution, summary table, three charts, download, JSON). The JSON stays LAST:
874
+ the gating tests read the answer as `outputs[-1]` and every slot between the caution and it
875
+ as "must show nothing on a denial", which the two new slots satisfy by returning a
876
+ hidden-visibility update on the denied path exactly as the charts do.
877
  """
878
  username = _username(profile)
879
  src = registered_source(source)
 
881
  if not allowed:
882
  record_run("variant_status", username=username, source=src, status="denied")
883
  _, js = _denied_json(denial)
884
+ yield (
885
+ _denial_caution_md(denial),
886
+ gr.update(value=None, visible=False),
887
+ *_CLEAR_PLOTS,
888
+ gr.update(visible=False),
889
+ js,
890
+ )
891
  return
892
+ # The clearing frame blanks the summary table and the download file for the same reason it
893
+ # blanks the charts: leaving the previous cohort's numbers on screen under the new cohort's
894
+ # caution is a wrong answer, and a stale download link is the worst version of it β€” the file
895
+ # would leave the page and be read later with no indication which query produced it. Values
896
+ # only, never visibility (see `_CLEAR_PLOTS`).
897
+ yield (gr.update(), gr.update(value=None), *_CLEAR_PLOTS, gr.update(value=None), gr.update())
898
  result = _safe_result(lambda: _query_variant_status(list(genes) or list(PANEL), source))
899
  record_run("variant_status", username=username, source=src, result=result)
900
+ yield (
901
+ _status_caution_md(result),
902
+ _summary_table(result),
903
+ *_frequency_plots(result),
904
+ _full_result_file(result, _safe_source_stem(result)),
905
+ # Abridged for the screen only β€” the button above serves the payload in full.
906
+ _ui_json(result),
907
+ )
908
 
909
 
910
  def _ui_variant_by_subtype(
 
1036
  # --------------------------------------------------------------------------- #
1037
  # App
1038
  # --------------------------------------------------------------------------- #
1039
+ # The height cap on the raw-JSON box, in CSS rather than in `gr.Code(max_lines=...)`.
1040
+ #
1041
+ # `max_lines` is NOT honoured by gradio 6.18's CodeMirror editor β€” measured in the browser, a
1042
+ # full-panel `pdac_msk_2024` answer still rendered a 23,323-pixel-tall box with `max_lines=25`
1043
+ # set, i.e. the box grows with the content exactly as before. The parameter is kept below as a
1044
+ # declaration of intent, but THIS is what enforces it. Without a cap the page height tracks the
1045
+ # payload, which is the whole bug: abridgement alone took the page from ~300,000px to ~25,000px,
1046
+ # and only this makes it constant.
1047
+ _CSS = """
1048
+ #vs-raw-json .cm-editor, #vs-raw-json .cm-scroller {
1049
+ max-height: 460px;
1050
+ overflow: auto;
1051
+ }
1052
+ """
1053
+
1054
+ with gr.Blocks(title="PDAC Genomics Agent", css=_CSS) as demo:
1055
  gr.Markdown(
1056
  "# PDAC Genomics Agent\n"
1057
  "Somatic **mutation**, **copy-number** and **structural-variant (fusion)** status over a "
 
1090
  )
1091
  vs_button = gr.Button("Query variant status", variant="primary")
1092
  vs_caution = gr.Markdown(visible=False)
1093
+ # Progressive disclosure, widest first: summary table -> charts -> full payload on
1094
+ # request -> abridged JSON. The table is deliberately ABOVE the charts β€” it carries the
1095
+ # fractions the bars are drawn from, and it is the only tier that can say "not on DNA
1096
+ # panel" rather than drawing nothing.
1097
+ vs_summary = gr.Dataframe(
1098
+ label="Alteration frequency by gene (cell = % altered, and the fraction it came from)",
1099
+ interactive=False,
1100
+ wrap=True,
1101
+ # Bounded height: 36 panel genes scroll INSIDE the table rather than lengthening the
1102
+ # page. The whole point of this change is that page height stops tracking data size.
1103
+ max_height=420,
1104
+ visible=False,
1105
+ )
1106
  # One chart per modality β€” see `_frequency_plots`. Never a single stacked chart: the
1107
  # modalities have different denominators and overlapping membership, so a stack is not
1108
  # a quantity. Each starts hidden and is shown only when the answer contains it.
1109
  vs_plots = [gr.BarPlot(visible=False) for _ in _MODALITY_ORDER]
1110
+ # The complete, unabridged payload β€” byte for byte what the machine endpoint returns.
1111
+ # It sits OUTSIDE the accordion, above it, so the abridged preview can never be mistaken
1112
+ # for the whole answer.
1113
+ vs_download = gr.DownloadButton("Download full result (JSON)", visible=False)
1114
+ with gr.Accordion("Raw result (JSON β€” abridged preview)", open=False):
1115
+ gr.Markdown(
1116
+ "Long sample-ID lists and per-sample maps are shown as a sentinel giving their "
1117
+ "exact size and first few entries, so this stays readable: a full-panel query on "
1118
+ "a 2,336-sample cohort is ~21,000 lines of JSON. **Nothing is withheld** β€” use "
1119
+ "*Download full result (JSON)* above for the complete payload."
1120
+ )
1121
+ # Height is bounded by `_CSS` via this elem_id, NOT by `max_lines` β€” see the note on
1122
+ # `_CSS`, which gradio 6.18 ignores here. Between them: the abridgement keeps what
1123
+ # you scroll through comprehensible, and the cap keeps the PAGE finite.
1124
+ vs_json = gr.Code(
1125
+ label="Result", language="json", lines=20, max_lines=25, elem_id="vs-raw-json"
1126
+ )
1127
  vs_button.click(
1128
  _ui_variant_status,
1129
  inputs=[vs_source, vs_genes],
1130
+ outputs=[vs_caution, vs_summary, *vs_plots, vs_download, vs_json],
1131
  api_name=False,
1132
  )
1133
 
tests/test_plot_and_caveat_rendering.py CHANGED
@@ -186,11 +186,23 @@ def test_variant_status_handler_clears_the_plot_before_answering():
186
  assert len(frames) == 2, "expected a clearing frame followed by the answer"
187
  clearing, answer = frames
188
 
189
- # The clearing frame blanks each chart's VALUE and leaves visibility alone. Hiding here
190
  # instead made every query a hideβ†’show flip, and the third chart lost that race about half
191
  # the time β€” it stayed unmounted ("null does not exist" in the browser), so a cohort WITH
192
  # structural variants rendered as mutation+CNV only, reading as "no fusions here".
193
- assert all(u is None for u in clearing[1:-1]), "clearing frame must blank, not hide"
 
 
 
 
 
 
 
 
 
 
 
 
194
  assert any(getattr(u, "value", None) is not None for u in answer[1:-1])
195
 
196
 
 
186
  assert len(frames) == 2, "expected a clearing frame followed by the answer"
187
  clearing, answer = frames
188
 
189
+ # The clearing frame blanks each result slot's VALUE and leaves visibility alone. Hiding here
190
  # instead made every query a hideβ†’show flip, and the third chart lost that race about half
191
  # the time β€” it stayed unmounted ("null does not exist" in the browser), so a cohort WITH
192
  # structural variants rendered as mutation+CNV only, reading as "no fusions here".
193
+ #
194
+ # Two spellings of "blanked", because the slots are no longer all charts: a chart clears to a
195
+ # bare `None`, while the summary table and the full-result download clear with an explicit
196
+ # `gr.update(value=None)`. The invariant is the same for all of them and is what this asserts
197
+ # β€” carry no value, and say nothing about visibility. The download slot matters most here: a
198
+ # stale file link would leave the page entirely and be opened later with nothing to say which
199
+ # query produced it.
200
+ for slot in clearing[1:-1]:
201
+ if slot is None:
202
+ continue
203
+ assert isinstance(slot, dict), f"unexpected clearing-frame slot: {slot!r}"
204
+ assert slot.get("value") is None, "clearing frame must carry no value"
205
+ assert "visible" not in slot, "clearing frame must blank, not hide"
206
  assert any(getattr(u, "value", None) is not None for u in answer[1:-1])
207
 
208
 
tests/test_raw_result_legibility.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The "Raw result (JSON)" accordion must stay readable β€” WITHOUT trimming the payload.
2
+
3
+ A full-panel `pdac_msk_2024` answer is ~21,000 lines / ~800 kB, and rendering it verbatim
4
+ produced a roughly 300,000-pixel page (found in the 2026-08-05 signed-in click-through). The
5
+ size is load-bearing, not waste:
6
+
7
+ * `samples_profiled` is the per-modality profiled universe as an ID SET, and the ONLY way a
8
+ machine caller can reconstruct wild-type from the sparse `per_sample`
9
+ (`deploy/orchestrator_registration.yaml`, `per_sample_contract`). Dropping it is what made
10
+ the orchestrator's join report every gene as "no variation in alteration status".
11
+ * `per_sample` / `provenance` are the per-sample answer itself.
12
+
13
+ So this is a RENDERING split, and these tests pin both halves of it: the machine endpoint still
14
+ emits the payload in full, and the UI's view is abridged, bounded, and self-describing. A change
15
+ that fixes the page by shortening the payload fails here.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+
22
+ import pytest
23
+
24
+ import gradio_ui
25
+ from src.tools.query_variant_status import query_variant_status
26
+
27
+ # The widest answer this deployment can produce: full panel over the largest cohort.
28
+ BIG_SOURCE = "cbioportal:pdac_msk_2024"
29
+
30
+
31
+ @pytest.fixture(scope="module")
32
+ def big_result():
33
+ return query_variant_status([], BIG_SOURCE)
34
+
35
+
36
+ # --------------------------------------------------------------------------- #
37
+ # The payload contract is untouched
38
+ # --------------------------------------------------------------------------- #
39
+ def test_full_payload_keeps_every_profiled_sample_id(big_result):
40
+ """`samples_profiled` stays a complete ID list β€” the orchestrator derives WT from it."""
41
+ for modality, ids in big_result["samples_profiled"].items():
42
+ assert isinstance(ids, list)
43
+ assert len(ids) == big_result["n_profiled"][modality]
44
+ assert all(isinstance(i, str) for i in ids)
45
+
46
+
47
+ def test_machine_json_is_the_unabridged_payload(big_result):
48
+ """`_as_json` β€” what every `gr.api` endpoint returns β€” carries the payload verbatim."""
49
+ body = json.loads(gradio_ui._as_json(big_result))
50
+ assert body == json.loads(json.dumps(big_result, default=str))
51
+ assert len(body["samples_profiled"]["mutation"]) == 2336
52
+
53
+
54
+ # --------------------------------------------------------------------------- #
55
+ # The UI view is abridged, bounded, and honest about it
56
+ # --------------------------------------------------------------------------- #
57
+ def test_ui_json_is_dramatically_smaller_than_the_payload(big_result):
58
+ full = gradio_ui._as_json(big_result).splitlines()
59
+ ui = gradio_ui._ui_json(big_result).splitlines()
60
+ assert len(full) > 20_000, "fixture no longer reproduces the size that caused the bug"
61
+ # Bounded by the PANEL (36 genes x 3 modalities), not by the cohort (2,336 samples) β€” that
62
+ # decoupling is the actual fix. The page can no longer grow with cohort size.
63
+ assert len(ui) < 2_500
64
+ assert len(ui) < len(full) / 8
65
+
66
+
67
+ def test_ui_json_replaces_big_collections_with_a_counted_sentinel(big_result):
68
+ """An abridged block states its TRUE size and is shaped so it cannot be misread as the data."""
69
+ body = json.loads(gradio_ui._ui_json(big_result))
70
+
71
+ profiled = body["samples_profiled"]["mutation"]
72
+ assert isinstance(profiled, dict), "a truncated list would still look like a list"
73
+ assert profiled["n_items"] == 2336 == big_result["n_profiled"]["mutation"]
74
+ assert len(profiled["first"]) == gradio_ui._ABRIDGE_PREVIEW
75
+
76
+ per_sample = body["genes"]["KRAS"]["mutation"]["per_sample"]
77
+ assert per_sample["n_entries"] == len(big_result["genes"]["KRAS"]["mutation"]["per_sample"])
78
+
79
+
80
+ def test_ui_json_says_it_is_abridged_and_where_the_full_thing_is(big_result):
81
+ body = json.loads(gradio_ui._ui_json(big_result))
82
+ note = body["__display__"]
83
+ assert "ABRIDGED" in note
84
+ assert "Download full result" in note
85
+
86
+
87
+ def test_frequencies_and_denominators_survive_abridgement(big_result):
88
+ """Only collections are abridged. Every NUMBER a reader might act on is untouched."""
89
+ body = json.loads(gradio_ui._ui_json(big_result))
90
+ for gene, entry in big_result["genes"].items():
91
+ for modality in ("mutation", "cnv", "sv"):
92
+ block = entry.get(modality)
93
+ if not isinstance(block, dict) or "frequency" not in block:
94
+ continue
95
+ shown = body["genes"][gene][modality]
96
+ assert shown["frequency"] == block["frequency"]
97
+ assert shown["n_altered"] == block["n_altered"]
98
+ assert shown["n_profiled"] == block["n_profiled"]
99
+
100
+
101
+ @pytest.mark.parametrize(
102
+ "payload",
103
+ [
104
+ {"status": "denied", "reason": "Please sign in."},
105
+ {"status": "refused", "reason": "non_human_species", "message": "not human"},
106
+ {"status": "error", "reason": "RuntimeError: cBioPortal is down"},
107
+ {"source": "cbioportal:paad_tcga", "n_samples": 3, "genes": {}},
108
+ ],
109
+ )
110
+ def test_small_payloads_pass_through_verbatim(payload):
111
+ """A denial/refusal/error is never rewritten β€” the visibility guarantees rest on its shape."""
112
+ assert json.loads(gradio_ui._ui_json(payload)) == payload
113
+
114
+
115
+ # --------------------------------------------------------------------------- #
116
+ # The summary table β€” the tier a non-coding scientist actually reads
117
+ # --------------------------------------------------------------------------- #
118
+ def test_summary_table_has_one_row_per_requested_gene(big_result):
119
+ frame = gradio_ui._summary_frame(big_result)
120
+ assert len(frame) == len(big_result["genes"])
121
+ assert list(frame["gene"]) == list(big_result["genes"])
122
+
123
+
124
+ def test_summary_table_never_pads_an_unassayed_gene_to_zero(big_result):
125
+ """The regression this repo has already fixed twice: a gene nobody sequenced is not 0%.
126
+
127
+ ELAVL1 is off `pdac_msk_2024`'s DNA panel and has no fusion events, so every one of its
128
+ cells must NAME the absence rather than show a percentage.
129
+ """
130
+ frame = gradio_ui._summary_frame(big_result)
131
+ row = frame[frame["gene"] == "ELAVL1"].iloc[0]
132
+ for modality in gradio_ui._MODALITY_ORDER:
133
+ cell = row[gradio_ui._MODALITY_LABELS[modality]]
134
+ assert "%" not in cell
135
+ assert cell == gradio_ui._CELL_NOT_ON_PANEL
136
+ assert "not a zero" in row[gradio_ui._SUMMARY_NOTE_COL]
137
+
138
+
139
+ def test_summary_table_honours_the_modality_scoped_gate(big_result):
140
+ """NRG1 (ADR-0008): off the DNA panel, but 6 real somatic rearrangements β€” SV shows,
141
+ mutation and CNV do not."""
142
+ frame = gradio_ui._summary_frame(big_result)
143
+ row = frame[frame["gene"] == "NRG1"].iloc[0]
144
+ assert row[gradio_ui._MODALITY_LABELS["mutation"]] == gradio_ui._CELL_NOT_ON_PANEL
145
+ assert row[gradio_ui._MODALITY_LABELS["cnv"]] == gradio_ui._CELL_NOT_ON_PANEL
146
+ sv = row[gradio_ui._MODALITY_LABELS["sv"]]
147
+ assert "%" in sv and "6 / 2336" in sv
148
+ assert "SV only" in row[gradio_ui._SUMMARY_NOTE_COL]
149
+
150
+
151
+ def test_summary_cell_states_the_fraction_not_just_the_percentage(big_result):
152
+ """"93.7%" alone hides the denominator, and the denominator is the thing people get wrong."""
153
+ frame = gradio_ui._summary_frame(big_result)
154
+ kras = frame[frame["gene"] == "KRAS"].iloc[0][gradio_ui._MODALITY_LABELS["mutation"]]
155
+ assert "93.7%" in kras
156
+ assert "2188 / 2336" in kras
157
+
158
+
159
+ def test_summary_table_hidden_when_there_is_nothing_to_show():
160
+ for empty in ({"genes": {}}, {"status": "denied"}, None):
161
+ assert gradio_ui._summary_table(empty)["visible"] is False
162
+
163
+
164
+ # --------------------------------------------------------------------------- #
165
+ # The download β€” the abridgement is only honest if the full thing is one click away
166
+ # --------------------------------------------------------------------------- #
167
+ def test_download_file_holds_the_complete_payload(big_result):
168
+ update = gradio_ui._full_result_file(big_result, gradio_ui._safe_source_stem(big_result))
169
+ assert update["visible"] is True
170
+ with open(update["value"], encoding="utf-8") as fh:
171
+ written = json.load(fh)
172
+ assert written == json.loads(json.dumps(big_result, default=str))
173
+ assert len(written["samples_profiled"]["cnv"]) == 2336