Skip to content

Commit 5323c57

Browse files
committed
fix(app): drop unsafe plotly_chart key, custom problem on by default
Crit-fix - The Solve page crashed with ``StreamlitDuplicateElementKey`` on the second callback tick: the previous "no flash" tweak attached a stable ``key="qqa_solve_dynamics"`` to a ``placeholder.plotly_chart`` call invoked many times within one script run, and Streamlit registered each invocation as a duplicate widget. The three live-update charts (dynamics, parallel population, diversity) now call ``placeholder.plotly_chart(fig, theme=None, config={"displayModeBar": False})`` with **no key**. ``theme=None`` skips Streamlit's per-call theme injection (the figure already carries our palette via ``plotly_layout``), which makes the redraw cheaper and noticeably reduces flash; hiding the modebar removes the modebar/title overlap that was visible in the user's screenshot. Custom-problem flow now first-class - ``ALLOW_CUSTOM`` defaults to **True** so the editor is reachable from the deployed UI without setting a server-side env var (``QQA_ALLOW_CUSTOM=0`` remains as an opt-out for shared hosting). - A curated example library lives in ``_common.CUSTOM_EXAMPLES`` (Spin glass, Number partitioning, Weighted MaxCut, Ferromagnetic Ising, Custom QUBO, Random 3-SAT). The editor surface adds a dropdown + "Load example" button that swaps the snippet with one click, plus a prominent ⚠️ banner spelling out that the snippet is ``exec``'d in the Streamlit process. Solve sidebar usability - 9 sliders → grouped into four ``st.expander``s (Population & schedule, Optimiser, Cooling / diversity, Display). Every slider now carries a ``help=`` tooltip and a stable ``key=``. - Three preset buttons at the top — 🏃 Fast smoke / 🎯 Default / 🔬 Thorough — write tuples into ``st.session_state`` and ``rerun()`` so the new defaults take effect immediately. Plotly polish - ``plotly_layout`` bumps top margin from 48 to 64 (and right margin to 28) so the title never collides with the Plotly modebar. Regression tests - ``test_solve_runs_with_default_mis`` clicks Run on the **default** MIS problem with a small epoch budget; would have caught the duplicate-key crash on the first tick. - ``test_custom_problem_available_by_default`` verifies that without ``QQA_ALLOW_CUSTOM`` the toggle and the curated example dropdown both render. Verified locally - ``uv run ruff check src tests scripts app`` — clean. - ``uv run ruff format --check src tests scripts app`` — clean. - ``uv run pytest -q`` — 55 passed, 1 skipped (was 53; +2 new GUI regression tests).
1 parent 3c9a9c5 commit 5323c57

4 files changed

Lines changed: 318 additions & 21 deletions

File tree

app/_common.py

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,85 @@ def loss_fn(s):
4242
return -0.5 * torch.einsum("bi,ij,bj->b", s, J, s)
4343
'''
4444

45+
# Curated example library shown in the Custom-problem editor's "Load
46+
# example" dropdown. Each entry is a self-contained snippet that defines
47+
# ``loss_fn(x)`` for one variable kind.
48+
CUSTOM_EXAMPLES: dict[str, str] = {
49+
"Spin glass (default SK)": DEFAULT_CUSTOM_SNIPPET,
50+
"Number partitioning (spin)": '''import torch
51+
52+
N = 32
53+
g = torch.Generator().manual_seed(0)
54+
a = torch.randint(1, 100, (N,), generator=g).float()
55+
56+
57+
def loss_fn(s):
58+
"""Minimise the squared imbalance Σ a_i s_i with s ∈ {-1,+1}^N."""
59+
return (s @ a) ** 2
60+
''',
61+
"Weighted MaxCut (binary)": '''import torch
62+
63+
N = 32
64+
g = torch.Generator().manual_seed(0)
65+
W = torch.rand(N, N, generator=g)
66+
W = (W + W.T) / 2
67+
W.fill_diagonal_(0.0)
68+
69+
70+
def loss_fn(x):
71+
"""Maximise Σ_{i<j} W_ij (x_i + x_j - 2 x_i x_j) — a weighted MaxCut.
72+
73+
The annealer minimises, so we negate.
74+
"""
75+
cut = torch.einsum("ij,bi,bj->b", W, x, 1 - x)
76+
return -cut
77+
''',
78+
"Ferromagnetic Ising chain": '''import torch
79+
80+
N = 64
81+
J = 1.0
82+
h = 0.0
83+
84+
85+
def loss_fn(s):
86+
"""1-D ferromagnet: -J Σ s_i s_{i+1} - h Σ s_i, s ∈ {-1,+1}."""
87+
return -J * (s[:, :-1] * s[:, 1:]).sum(dim=1) - h * s.sum(dim=1)
88+
''',
89+
"Custom QUBO from a matrix": '''import torch
90+
91+
# Replace Q with your own (N×N) matrix. The energy is x^T Q x.
92+
N = 24
93+
g = torch.Generator().manual_seed(7)
94+
Q = torch.randn(N, N, generator=g)
95+
Q = (Q + Q.T) / 2
96+
97+
98+
def loss_fn(x):
99+
"""Generic QUBO loss for binary x ∈ {0,1}^N."""
100+
return torch.einsum("ij,bi,bj->b", Q, x, x)
101+
''',
102+
"Random 3-SAT clause loss (binary)": """import torch
103+
104+
N = 30
105+
M = 90 # clauses
106+
g = torch.Generator().manual_seed(0)
107+
lit = torch.randint(0, 2 * N, (M, 3), generator=g) # variable-with-sign codes
108+
sign = (lit % 2 == 0).float() * 2.0 - 1.0 # +1 if positive literal, -1 if negated
109+
var = lit // 2
110+
111+
112+
def loss_fn(x):
113+
# Map x ∈ {0,1} to ±1 literal evaluations.
114+
spins = 2 * x - 1.0 # (B, N)
115+
chosen = spins[:, var] # (B, M, 3)
116+
eval_lit = chosen * sign # +1 if literal satisfied
117+
# Clause unsatisfied iff every literal is -1, i.e. product == -1
118+
# easier: clause "value" = max over literals; we approximate with mean.
119+
sat = ((eval_lit + 1) / 2).max(dim=-1).values # (B, M)
120+
return (1.0 - sat).sum(dim=-1)
121+
""",
122+
}
123+
45124

46125
# ---------------------------------------------------------------------------
47126
# Theme helpers
@@ -134,14 +213,16 @@ def plotly_layout(theme: str | None = None, **overrides) -> dict:
134213
"font": {"family": "Inter, -apple-system, sans-serif", "size": 13, "color": p["text"]},
135214
"title_font": {
136215
"family": "'Source Serif 4', Georgia, serif",
137-
"size": 17,
216+
"size": 16,
138217
"color": p["text"],
139218
},
140219
"colorway": p["palette"],
141220
"xaxis": {"gridcolor": p["grid"], "linecolor": p["border"], "zerolinecolor": p["grid"]},
142221
"yaxis": {"gridcolor": p["grid"], "linecolor": p["border"], "zerolinecolor": p["grid"]},
143222
"legend": {"bgcolor": "rgba(0,0,0,0)", "bordercolor": p["border"], "borderwidth": 0.5},
144-
"margin": {"l": 50, "r": 20, "t": 48, "b": 46},
223+
# Top margin keeps the title clear of Plotly's modebar (which lives
224+
# in the top-right corner). Was 48, and even modest titles collided.
225+
"margin": {"l": 56, "r": 28, "t": 64, "b": 50},
145226
}
146227
base.update(overrides)
147228
return base

app/pages/1_Solve.py

Lines changed: 139 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -43,17 +43,131 @@
4343
f"device: **{cfg['device']}** | seed: **{cfg['seed']}**"
4444
)
4545

46+
# Hyper-parameter presets — each tuple is
47+
# (sol_size, epochs, learning_rate, temp, min_bg, max_bg, curve_rate,
48+
# div_param, update_every).
49+
_PRESETS = {
50+
"🏃 Fast smoke": (32, 200, 1.0, 0.0, -2.0, 0.1, 2, 0.0, 10),
51+
"🎯 Default": (64, 1000, 1.0, 0.0, -2.0, 0.1, 2, 0.0, 20),
52+
"🔬 Thorough": (128, 3000, 0.7, 0.05, -3.0, 0.2, 4, 0.0, 50),
53+
}
54+
55+
56+
def _apply_preset(name: str) -> None:
57+
"""Write preset values into ``st.session_state`` so the widgets pick
58+
them up on the next rerun."""
59+
keys = (
60+
"sol_size",
61+
"epochs",
62+
"learning_rate",
63+
"temp",
64+
"min_bg",
65+
"max_bg",
66+
"curve_rate",
67+
"div_param",
68+
"update_every",
69+
)
70+
for k, v in zip(keys, _PRESETS[name], strict=True):
71+
st.session_state[k] = v
72+
73+
4674
with st.sidebar:
4775
st.header("2 · QQA hyper-parameters")
48-
sol_size = st.slider("sol_size (parallel population)", 4, 400, 64)
49-
epochs = st.slider("epochs", 100, 5000, 1000, step=100)
50-
learning_rate = st.slider("learning rate", 0.05, 3.0, 1.0, 0.05)
51-
temp = st.slider("Langevin temperature", 0.0, 1.0, 0.0, 0.01)
52-
min_bg = st.slider("min bg", -5.0, 0.0, -2.0, 0.1)
53-
max_bg = st.slider("max bg", 0.0, 2.0, 0.1, 0.1)
54-
curve_rate = st.selectbox("curve rate", (2, 4, 6), index=0)
55-
div_param = st.slider("div_param", 0.0, 1.0, 0.0, 0.01)
56-
update_every = st.slider("UI update every (epochs)", 1, 200, 20)
76+
preset_name = st.radio(
77+
"Preset",
78+
list(_PRESETS),
79+
index=1,
80+
horizontal=False,
81+
help="Quickly seed every slider below. You can still tweak any value.",
82+
)
83+
if st.button("Apply preset", width="stretch"):
84+
_apply_preset(preset_name)
85+
st.rerun()
86+
87+
with st.expander("Population & schedule", expanded=True):
88+
sol_size = st.slider(
89+
"sol_size",
90+
4,
91+
400,
92+
st.session_state.get("sol_size", 64),
93+
key="sol_size",
94+
help="Number of parallel replicas annealed in lockstep.",
95+
)
96+
epochs = st.slider(
97+
"epochs",
98+
100,
99+
5000,
100+
st.session_state.get("epochs", 1000),
101+
step=100,
102+
key="epochs",
103+
help="Total annealing iterations.",
104+
)
105+
curve_rate = st.selectbox(
106+
"curve rate",
107+
(2, 4, 6),
108+
index=(2, 4, 6).index(st.session_state.get("curve_rate", 2)),
109+
key="curve_rate",
110+
help="Steepness of the bias schedule (higher = more abrupt).",
111+
)
112+
113+
with st.expander("Optimiser", expanded=False):
114+
learning_rate = st.slider(
115+
"learning rate",
116+
0.05,
117+
3.0,
118+
st.session_state.get("learning_rate", 1.0),
119+
0.05,
120+
key="learning_rate",
121+
help="Adam step size for the relaxed variables.",
122+
)
123+
temp = st.slider(
124+
"Langevin temperature",
125+
0.0,
126+
1.0,
127+
st.session_state.get("temp", 0.0),
128+
0.01,
129+
key="temp",
130+
help="Magnitude of the stochastic noise injected each step (0 = deterministic).",
131+
)
132+
133+
with st.expander("Cooling / diversity", expanded=False):
134+
min_bg = st.slider(
135+
"min bg",
136+
-5.0,
137+
0.0,
138+
st.session_state.get("min_bg", -2.0),
139+
0.1,
140+
key="min_bg",
141+
help="Initial bias-gain (smooth, exploratory).",
142+
)
143+
max_bg = st.slider(
144+
"max bg",
145+
0.0,
146+
2.0,
147+
st.session_state.get("max_bg", 0.1),
148+
0.1,
149+
key="max_bg",
150+
help="Final bias-gain (sharp, near-discrete).",
151+
)
152+
div_param = st.slider(
153+
"div_param",
154+
0.0,
155+
1.0,
156+
st.session_state.get("div_param", 0.0),
157+
0.01,
158+
key="div_param",
159+
help="Repulsion strength between replicas (0 = independent runs).",
160+
)
161+
162+
with st.expander("Display", expanded=False):
163+
update_every = st.slider(
164+
"UI update every (epochs)",
165+
1,
166+
200,
167+
st.session_state.get("update_every", 20),
168+
key="update_every",
169+
help="Lower = smoother animation but slower wall-clock; higher = faster.",
170+
)
57171

58172

59173
class StreamlitCallback(Callback):
@@ -154,7 +268,16 @@ def on_epoch_end(self, state: CallbackState) -> None:
154268
legend={"x": 0.01, "y": 0.02, "bgcolor": "rgba(255,255,255,0.6)"},
155269
)
156270
)
157-
self.chart_holder.plotly_chart(fig, width="stretch", key="qqa_solve_dynamics")
271+
# IMPORTANT: do NOT pass ``key=`` here. ``st.empty().plotly_chart`` is
272+
# invoked many times within one script run (once per ``update_every``
273+
# epochs); a stable key would collide with itself and trip
274+
# StreamlitDuplicateElementKey. ``theme=None`` skips Streamlit's
275+
# per-call theme-injection step (the figure already carries our
276+
# palette via ``plotly_layout``) which makes the redraw cheaper and
277+
# noticeably reduces flash on the live charts.
278+
self.chart_holder.plotly_chart(
279+
fig, width="stretch", theme=None, config={"displayModeBar": False}
280+
)
158281

159282
# --- Population heatmap: replicas sorted by best-so-far --------
160283
pop = np.stack(self.pop, axis=1) # (sol_size, T)
@@ -200,7 +323,9 @@ def on_epoch_end(self, state: CallbackState) -> None:
200323
legend={"x": 0.01, "y": 0.99, "bgcolor": "rgba(255,255,255,0.6)"},
201324
)
202325
)
203-
self.pop_holder.plotly_chart(pop_fig, width="stretch", key="qqa_solve_population")
326+
self.pop_holder.plotly_chart(
327+
pop_fig, width="stretch", theme=None, config={"displayModeBar": False}
328+
)
204329

205330
# --- Diversity curve: std across replicas vs epoch --------------
206331
div_fig = go.Figure()
@@ -224,7 +349,9 @@ def on_epoch_end(self, state: CallbackState) -> None:
224349
showlegend=False,
225350
)
226351
)
227-
self.diversity_holder.plotly_chart(div_fig, width="stretch", key="qqa_solve_diversity")
352+
self.diversity_holder.plotly_chart(
353+
div_fig, width="stretch", theme=None, config={"displayModeBar": False}
354+
)
228355

229356

230357
run = st.button("▶ Run QQA", type="primary")

app/streamlit_app.py

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
import streamlit as st # noqa: E402
2424
from _common import ( # noqa: E402
25+
CUSTOM_EXAMPLES,
2526
DEFAULT_CUSTOM_SNIPPET,
2627
apply_theme,
2728
build_problem,
@@ -34,11 +35,12 @@
3435

3536
import qqa # noqa: E402
3637

37-
# The custom-problem editor runs user-supplied Python via ``exec``. Disable
38-
# it on public deployments by setting ``QQA_ALLOW_CUSTOM=0`` (this is the
39-
# default on Streamlit Community Cloud / Hugging Face Spaces). Set it to
40-
# ``1`` to re-enable on a trusted machine.
41-
ALLOW_CUSTOM = os.getenv("QQA_ALLOW_CUSTOM", "0") == "1"
38+
# The custom-problem editor runs user-supplied Python via ``exec`` inside
39+
# the Streamlit process. We expose it from the UI by default — this is a
40+
# public research tool, not a multi-tenant service — but make the security
41+
# trade-off explicit via a banner and an opt-out env var
42+
# (``QQA_ALLOW_CUSTOM=0`` hides the editor on shared deployments).
43+
ALLOW_CUSTOM = os.getenv("QQA_ALLOW_CUSTOM", "1") == "1"
4244

4345
st.set_page_config(
4446
page_title="QQA dashboard",
@@ -74,7 +76,11 @@
7476
use_custom = st.toggle(
7577
"Use custom problem",
7678
value=False,
77-
help="Plug in your own loss_fn directly from this UI.",
79+
help=(
80+
"Plug in your own loss_fn directly from this UI. The code "
81+
"runs in this Streamlit process — only paste code you trust."
82+
),
83+
key="use_custom",
7884
)
7985
else:
8086
use_custom = False
@@ -202,12 +208,35 @@
202208
# ---------------------------------------------------------------------------
203209
if use_custom:
204210
st.subheader("Custom loss editor")
211+
st.warning(
212+
"⚠️ The snippet below is executed via Python `exec` inside this "
213+
"Streamlit process. Only paste code you trust; the editor is "
214+
"intentionally exposed for research use, not for hosting "
215+
"untrusted user code.",
216+
icon="⚠️",
217+
)
205218
st.markdown(
206219
"Define a function named `loss_fn(x)` that maps a batched configuration "
207220
"tensor to a `(B,)` loss vector. The namespace already has `torch` and "
208221
"`np` (numpy) imported. Any constants (couplings, patterns, ...) you "
209222
"declare at module scope are captured by closure."
210223
)
224+
225+
example_keys = list(CUSTOM_EXAMPLES)
226+
col_l, col_r = st.columns([2, 1])
227+
with col_l:
228+
example_choice = st.selectbox(
229+
"Load example",
230+
example_keys,
231+
index=0,
232+
help="Replace the editor with a fully-working snippet.",
233+
)
234+
with col_r:
235+
st.write("")
236+
if st.button("Load example", width="stretch"):
237+
st.session_state["custom_source"] = CUSTOM_EXAMPLES[example_choice]
238+
st.rerun()
239+
211240
source = st.text_area(
212241
"Snippet",
213242
value=st.session_state.get("custom_source", DEFAULT_CUSTOM_SNIPPET),

0 commit comments

Comments
 (0)