Skip to content

Commit 6f9a6dc

Browse files
author
Yuma Ichikawa
committed
feat(visualize): backend-aware tab layout (PQQA-only vs PA-only)
The Visualize page used to advertise 11 shared tabs plus 6 PA-extra tabs. On a PA run, 7 of the 11 shared tabs (Schedule, Parallel population, Solution-space PCA, Diversity, Loss spectrogram, Ridgeline, Replica fate) all rendered a single "No population snapshots recorded for this run." card because they depend on the ``PopulationTracker`` that only PQQA produces, polluting the tab bar. Symmetrically, a PQQA run already hid the 6 PA-only tabs but its PA-flavoured narrative was absent from the Visualize layout. This patch splits the tabs into three groups and renders only the universal + backend-matching ones: - Universal (both): Solution, Dynamics, Best trajectory, Family tree (which is already backend-aware internally — Muller plot for PA, hierarchical-clustering dendrogram for PQQA). - PQQA-only: Schedule, Parallel population, Solution-space PCA, Diversity, Loss spectrogram, Ridgeline, Replica fate (all depend on PopulationTracker). - PA-only: PA ESS / β, PA Free energy, PA Equilibrium pop., PA Thermodynamics, PA Lineage vs energy, PA Ancestry Sankey. The 7 existing ``with tab_X: ...`` rendering blocks are kept structurally intact via a ``_discard_tab_writes()`` context manager that creates a ``st.empty()`` placeholder, catches every write inside its ``container()``, and calls ``placeholder.empty()`` on ``__exit__`` so the DOM carries no trace of the absorbed output. Zero reindentation needed — ``_tab_or_noop(key)`` returns the real tab handle when the key is in this run's layout, and a noop discard context otherwise. Verification: - ``test_visualize_page_renders_pa_result`` now also asserts the 7 PQQA-only tab labels are ABSENT from the PA tab bar. - ``test_visualize_pqqa_family_tree_renders_dendrogram`` now also asserts the 6 PA-only tab labels are ABSENT from the PQQA tab bar and that the 7 PQQA-only tabs ARE present. - ``tasks/test/check_visualize_leakage.py`` drives both backends end-to-end and scans ``at.info / at.markdown / at.caption`` for telltale phrases ("No population snapshots recorded" / "Lineage vs energy" / ...) that would indicate a leak. Confirmed clean in both directions. - 23 GUI AppTests + 320 unit tests pass.
1 parent 4292ba9 commit 6f9a6dc

2 files changed

Lines changed: 161 additions & 43 deletions

File tree

app/pages/2_Visualize.py

Lines changed: 113 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -88,51 +88,121 @@ class PAResult: # type: ignore[no-redef]
8888
# * PQQA → hierarchical-clustering dendrogram + per-clade energy evolution
8989
# That answers the user request to have a tree-style view that *adapts*
9090
# to the selected backend rather than hiding behind a "PA-only" label.
91-
base_tabs = [
92-
"Solution",
93-
"Dynamics",
94-
"Best trajectory",
95-
"Schedule",
96-
"Parallel population",
97-
"Solution-space PCA",
98-
"Diversity",
99-
"Loss spectrogram",
100-
"Ridgeline",
101-
"Replica fate",
102-
"Family tree",
91+
# ---------------------------------------------------------------------------
92+
# Backend-aware tab layout.
93+
#
94+
# PQQA runs carry a ``PopulationTracker`` (per-epoch replica snapshots, used
95+
# by Population / PCA / Diversity / Spectrogram / Ridgeline / Replica-fate)
96+
# plus a PQQA-only ``history["bg"]`` schedule; PA runs carry a genealogy,
97+
# ESS / log-Z history, and equilibrium samples instead. Rendering both
98+
# sets on every run pollutes the UI with "No population snapshots
99+
# recorded" cards under PA and empty "PA: ..." tabs under PQQA. Show only
100+
# the tabs that apply to whichever backend produced ``result``.
101+
#
102+
# Layout:
103+
# * Universal (both backends): Solution, Dynamics, Best trajectory,
104+
# Family tree (tab_tree renders a backend-aware dendrogram / Muller
105+
# plot internally).
106+
# * PQQA-only: Schedule, Parallel population, Solution-space PCA,
107+
# Diversity, Loss spectrogram, Ridgeline, Replica fate.
108+
# * PA-only: PA ESS / β, PA Free energy, PA Equilibrium pop., PA
109+
# Thermodynamics, PA Lineage vs energy, PA Ancestry Sankey.
110+
# ---------------------------------------------------------------------------
111+
_UNIVERSAL_TAB_KEYS: list[str] = ["sol", "hist", "best"]
112+
_PQQA_TAB_KEYS: list[str] = ["sched", "pop", "pca", "div", "spec", "ridge", "fate"]
113+
_PA_TAB_KEYS: list[str] = [
114+
"pa_ess",
115+
"pa_fe",
116+
"pa_eq",
117+
"pa_thermo",
118+
"pa_lineage",
119+
"pa_sankey",
103120
]
104-
pa_extra_tabs = [
105-
"PA: ESS / β",
106-
"PA: Free energy",
107-
"PA: Equilibrium pop.",
108-
"PA: Thermodynamics",
109-
"PA: Lineage vs energy",
110-
"PA: Ancestry Sankey",
111-
]
112-
all_tabs = base_tabs + (pa_extra_tabs if is_pa else [])
113-
_tabs = st.tabs(all_tabs)
114-
(
115-
tab_sol,
116-
tab_hist,
117-
tab_best,
118-
tab_sched,
119-
tab_pop,
120-
tab_pca,
121-
tab_div,
122-
tab_spec,
123-
tab_ridge,
124-
tab_fate,
125-
tab_tree,
126-
) = _tabs[:11]
121+
# ``tree`` is universal too but we put it at the end of each backend's layout
122+
# to keep the narrative flow "what → how → why → lineage".
123+
_TREE_TAB_KEY: str = "tree"
124+
125+
_TAB_LABELS: dict[str, str] = {
126+
"sol": "Solution",
127+
"hist": "Dynamics",
128+
"best": "Best trajectory",
129+
"sched": "Schedule",
130+
"pop": "Parallel population",
131+
"pca": "Solution-space PCA",
132+
"div": "Diversity",
133+
"spec": "Loss spectrogram",
134+
"ridge": "Ridgeline",
135+
"fate": "Replica fate",
136+
"tree": "Family tree",
137+
"pa_ess": "PA: ESS / β",
138+
"pa_fe": "PA: Free energy",
139+
"pa_eq": "PA: Equilibrium pop.",
140+
"pa_thermo": "PA: Thermodynamics",
141+
"pa_lineage": "PA: Lineage vs energy",
142+
"pa_sankey": "PA: Ancestry Sankey",
143+
}
144+
127145
if is_pa:
128-
(
129-
tab_pa_ess,
130-
tab_pa_fe,
131-
tab_pa_eq,
132-
tab_pa_thermo,
133-
tab_pa_lineage,
134-
tab_pa_sankey,
135-
) = _tabs[11:17]
146+
_ordered_keys = _UNIVERSAL_TAB_KEYS + _PA_TAB_KEYS + [_TREE_TAB_KEY]
147+
else:
148+
_ordered_keys = _UNIVERSAL_TAB_KEYS + _PQQA_TAB_KEYS + [_TREE_TAB_KEY]
149+
150+
_tabs = st.tabs([_TAB_LABELS[k] for k in _ordered_keys])
151+
_tab_by_key: dict[str, "st.delta_generator.DeltaGenerator"] = dict(
152+
zip(_ordered_keys, _tabs, strict=True)
153+
)
154+
155+
156+
@contextlib.contextmanager
157+
def _discard_tab_writes():
158+
"""Context manager that absorbs every Streamlit call into a placeholder
159+
that is emptied on exit.
160+
161+
Used as a stand-in for tabs that are not part of the current backend's
162+
layout (e.g. ``Schedule`` / ``Parallel population`` for a PA run) so
163+
the existing ``with tab_X: ...`` rendering blocks below don't need to
164+
be reindented or rewritten — they simply emit into a container that is
165+
cleared the instant the ``with`` exits, leaving no trace in the page.
166+
The ``st.tabs([...])`` label list already omits these tabs, so the
167+
tab bar never advertises them either.
168+
"""
169+
placeholder = st.empty()
170+
try:
171+
with placeholder.container():
172+
yield
173+
finally:
174+
placeholder.empty()
175+
176+
177+
def _tab_or_noop(key: str):
178+
"""Return the real tab DG for ``key`` if it exists in this run's
179+
layout, otherwise a write-absorbing noop context. Invariant: every
180+
``with tab_X:`` block in the rest of this page must go through this
181+
accessor so it never receives ``None``.
182+
"""
183+
real = _tab_by_key.get(key)
184+
if real is not None:
185+
return real
186+
return _discard_tab_writes()
187+
188+
189+
tab_sol = _tab_or_noop("sol")
190+
tab_hist = _tab_or_noop("hist")
191+
tab_best = _tab_or_noop("best")
192+
tab_sched = _tab_or_noop("sched")
193+
tab_pop = _tab_or_noop("pop")
194+
tab_pca = _tab_or_noop("pca")
195+
tab_div = _tab_or_noop("div")
196+
tab_spec = _tab_or_noop("spec")
197+
tab_ridge = _tab_or_noop("ridge")
198+
tab_fate = _tab_or_noop("fate")
199+
tab_tree = _tab_or_noop("tree")
200+
tab_pa_ess = _tab_or_noop("pa_ess")
201+
tab_pa_fe = _tab_or_noop("pa_fe")
202+
tab_pa_eq = _tab_or_noop("pa_eq")
203+
tab_pa_thermo = _tab_or_noop("pa_thermo")
204+
tab_pa_lineage = _tab_or_noop("pa_lineage")
205+
tab_pa_sankey = _tab_or_noop("pa_sankey")
136206

137207

138208
def _retheme(fig):

tests/test_gui_apptest.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,24 @@ def test_visualize_page_renders_pa_result():
228228
assert extra_tab in tab_labels, (
229229
f"PA-specific visualize tab {extra_tab!r} missing; got {tab_labels!r}"
230230
)
231+
# Backend-aware layout contract: PQQA-only tabs (driven by the
232+
# ``PopulationTracker`` that PA never produces) MUST NOT appear on a
233+
# PA run. The user explicitly asked for this — otherwise the tab bar
234+
# advertises six tabs that all just say "No population snapshots
235+
# recorded for this run." and clutter the UI.
236+
for pqqa_only_tab in (
237+
"Schedule",
238+
"Parallel population",
239+
"Solution-space PCA",
240+
"Diversity",
241+
"Loss spectrogram",
242+
"Ridgeline",
243+
"Replica fate",
244+
):
245+
assert pqqa_only_tab not in tab_labels, (
246+
f"PQQA-only tab {pqqa_only_tab!r} must be hidden on a PA "
247+
f"run; got tabs = {tab_labels!r}"
248+
)
231249

232250

233251
def test_visualize_pqqa_family_tree_renders_dendrogram(tmp_path):
@@ -270,6 +288,36 @@ def test_visualize_pqqa_family_tree_renders_dendrogram(tmp_path):
270288
assert "Family tree" in tab_labels, (
271289
f"Family tree tab must be present for PQQA results; got {tab_labels!r}"
272290
)
291+
# Backend-aware contract (mirror of the PA-run test): PA-only tabs
292+
# MUST NOT appear on a PQQA run. Otherwise we advertise six empty
293+
# tabs ("no free-energy history" / "no genealogy" etc).
294+
for pa_only_tab in (
295+
"PA: ESS / β",
296+
"PA: Free energy",
297+
"PA: Equilibrium pop.",
298+
"PA: Thermodynamics",
299+
"PA: Lineage vs energy",
300+
"PA: Ancestry Sankey",
301+
):
302+
assert pa_only_tab not in tab_labels, (
303+
f"PA-only tab {pa_only_tab!r} must be hidden on a PQQA "
304+
f"run; got tabs = {tab_labels!r}"
305+
)
306+
# PQQA-only tabs (what the Visualize page is designed around when
307+
# the run came from ``qqa.anneal``) must still be present.
308+
for pqqa_tab in (
309+
"Schedule",
310+
"Parallel population",
311+
"Solution-space PCA",
312+
"Diversity",
313+
"Loss spectrogram",
314+
"Ridgeline",
315+
"Replica fate",
316+
):
317+
assert pqqa_tab in tab_labels, (
318+
f"PQQA tab {pqqa_tab!r} must be present on a PQQA run; "
319+
f"got {tab_labels!r}"
320+
)
273321

274322

275323
def test_solve_page_pa_backend_smoke_run():

0 commit comments

Comments
 (0)