Skip to content

Commit 55ecd2c

Browse files
committed
fix(ui): harden PA backend against stale qqa wheel (Streamlit Cloud)
The deployed Streamlit instance was pinning qqa==0.5.0 from PyPI (which predates Population Annealing) instead of the in-tree source, so the PA button raised "module 'qqa' has no attribute 'population_annealing'". - Solve page detects PA capability and only offers it when available; shows a clear st.warning with the installed qqa version otherwise. - Visualize page tolerates a missing PAResult symbol via a sentinel. - Bump qqa to 0.5.2 so pip stops short-circuiting the reinstall. - Two AppTest regressions monkeypatch the symbols away to lock the fix.
1 parent fcca220 commit 55ecd2c

5 files changed

Lines changed: 106 additions & 3 deletions

File tree

app/pages/1_Solve.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,14 @@
4040
import qqa # noqa: E402
4141
from qqa.callbacks import Callback, CallbackState, PopulationTracker # noqa: E402
4242

43+
# Capability detection — the deployed ``qqa`` wheel is occasionally older
44+
# than the app source (e.g. Streamlit Community Cloud caches the previous
45+
# build, or pip resolves ``qqa`` from PyPI when ``.`` install fails). We
46+
# probe instead of relying on the symbol being present, so the page never
47+
# crashes with a cryptic ``module 'qqa' has no attribute …`` error.
48+
PA_AVAILABLE = hasattr(qqa, "population_annealing") and hasattr(qqa, "PAResult")
49+
QQA_VERSION = getattr(qqa, "__version__", "unknown")
50+
4351
st.set_page_config(page_title="Solve — QQA", page_icon="⚛️", layout="wide")
4452
sidebar_brand()
4553
theme_toggle_in_sidebar()
@@ -65,9 +73,10 @@
6573

6674
with st.sidebar:
6775
st.header("2 · Backend")
76+
_backend_options = ["PQQA"] + (["PA (Population Annealing)"] if PA_AVAILABLE else [])
6877
backend = st.radio(
6978
"Solver backend",
70-
["PQQA", "PA (Population Annealing)"],
79+
_backend_options,
7180
index=0,
7281
help=(
7382
"PQQA = Parallel Quasi-Quantum Annealing (gradient-based, the "
@@ -77,6 +86,14 @@
7786
),
7887
key="solve_backend",
7988
)
89+
if not PA_AVAILABLE:
90+
st.warning(
91+
f"PA backend not available in this build (qqa = `{QQA_VERSION}`). "
92+
"PA needs `qqa.population_annealing`, added in 0.5.1+. "
93+
"Re-deploy with the latest source (`pip install -e .` from the "
94+
"repo root) or pull a newer wheel.",
95+
icon=":material/upgrade:",
96+
)
8097

8198
# Hyper-parameter presets — each tuple is
8299
# (sol_size, epochs, learning_rate, temp, min_bg, max_bg, curve_rate,
@@ -696,6 +713,14 @@ def on_epoch_end(self, state: CallbackState) -> None:
696713
# Population Annealing (PA) backend
697714
# =============================================================================
698715
if run and backend != "PQQA":
716+
if not PA_AVAILABLE:
717+
st.error(
718+
f"Population Annealing is not exposed by the installed `qqa` "
719+
f"(version `{QQA_VERSION}`). The deployed wheel is older than this "
720+
"Streamlit app — re-deploy with the latest source so "
721+
"`qqa.population_annealing` is importable."
722+
)
723+
st.stop()
699724
try:
700725
problem = build_problem(cfg)
701726
except Exception as e:

app/pages/2_Visualize.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,20 @@
2222
)
2323
from _solution_viz import render_solution_view # noqa: E402
2424

25-
from qqa import PAResult # noqa: E402
2625
from qqa import visualization as viz # noqa: E402
2726

27+
# Capability detection — see comment in 1_Solve.py. PAResult only exists
28+
# from qqa 0.5.1 onwards; older deployed wheels would crash this page on
29+
# import. We fall back to a sentinel class so ``isinstance`` is False
30+
# everywhere and the PA-specific tabs simply don't render.
31+
try:
32+
from qqa import PAResult # noqa: E402
33+
except ImportError:
34+
35+
class PAResult: # type: ignore[no-redef]
36+
"""Stand-in used when the deployed qqa is too old to expose PAResult."""
37+
38+
2839
st.set_page_config(page_title="Visualize — QQA", page_icon="⚛️", layout="wide")
2940
sidebar_brand()
3041
theme_toggle_in_sidebar()

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "qqa"
7-
version = "0.5.1"
7+
version = "0.5.2"
88
description = "Quasi-Quantum Annealing (QQA): a general-purpose GPU solver for combinatorial and spin-glass optimization, with PI-GNN/CPRA and a parallel SA baseline."
99
readme = "README.md"
1010
requires-python = ">=3.10"

requirements.txt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,12 @@ pandas>=2.0
2828
streamlit>=1.30
2929

3030
# Install this repository so ``import qqa`` works inside the app.
31+
# IMPORTANT: must be the LAST line. We bump pyproject.toml's ``version``
32+
# field on every release that adds a public symbol (e.g. PA was added in
33+
# 0.5.1, equilibrium-sample/genealogy/Muller-plot in 0.5.2). Without a
34+
# version bump pip's resolver short-circuits when the previously
35+
# installed ``qqa`` already matches the pinned spec — Streamlit
36+
# Community Cloud has been observed to keep ``qqa==0.5.0`` from a stale
37+
# build and silently skip reinstalling, which then makes
38+
# ``qqa.population_annealing`` missing at runtime.
3139
.

tests/test_gui_apptest.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,65 @@ def test_solve_page_pa_backend_smoke_run():
270270
)
271271

272272

273+
def test_solve_page_survives_old_qqa_without_population_annealing(monkeypatch):
274+
"""Regression for ``module 'qqa' has no attribute 'population_annealing'``.
275+
276+
Reproduces the production bug seen on Streamlit Community Cloud when
277+
pip resolved ``qqa==0.5.0`` from PyPI (which predates PA) instead of
278+
the in-tree wheel. The Solve page must:
279+
280+
* not crash on import / first render,
281+
* not present PA as a selectable backend, and
282+
* surface a human-readable ``st.warning`` explaining the missing
283+
capability so the user knows to redeploy.
284+
"""
285+
import qqa as _qqa # noqa: PLC0415
286+
287+
monkeypatch.delattr(_qqa, "population_annealing", raising=False)
288+
monkeypatch.delattr(_qqa, "PAResult", raising=False)
289+
290+
at = AppTest.from_file(str(PAGE_DIR / "1_Solve.py"), default_timeout=60)
291+
at.session_state["problem_config"] = {
292+
"kind": "ising1d",
293+
"size": 6,
294+
"seed": 0,
295+
"device": "cpu",
296+
"extra": {},
297+
}
298+
at.run()
299+
assert not at.exception, at.exception
300+
301+
backend_radios = [r for r in at.sidebar.radio if "backend" in r.label.lower()]
302+
assert backend_radios, "Backend radio missing"
303+
options = list(backend_radios[0].options)
304+
assert "PQQA" in options
305+
assert not any("PA" in opt for opt in options), (
306+
f"PA option must be hidden when qqa.population_annealing is absent; got {options!r}"
307+
)
308+
309+
warnings = [getattr(w, "body", "") or getattr(w, "value", "") for w in at.warning]
310+
assert any("PA backend not available" in (b or "") for b in warnings), (
311+
f"Capability-missing warning not shown; warnings={warnings!r}"
312+
)
313+
314+
315+
def test_visualize_page_survives_old_qqa_without_pa_result(monkeypatch):
316+
"""Visualize page must not crash if the deployed qqa lacks ``PAResult``.
317+
318+
Older wheels (pre-0.5.1) do not expose ``PAResult``; the page used to
319+
do a top-level ``from qqa import PAResult`` which would raise on
320+
import. The hardened page now degrades gracefully — it should render
321+
the empty state when no run is loaded, instead of crashing.
322+
"""
323+
import qqa as _qqa # noqa: PLC0415
324+
325+
monkeypatch.delattr(_qqa, "PAResult", raising=False)
326+
327+
at = AppTest.from_file(str(PAGE_DIR / "2_Visualize.py"), default_timeout=60)
328+
at.run()
329+
assert not at.exception, at.exception
330+
331+
273332
def test_solve_runs_with_default_mis():
274333
"""Regression for the duplicate-element-key crash on the default MIS
275334
problem.

0 commit comments

Comments
 (0)