Skip to content

Commit dd0cd5f

Browse files
committed
ui: harden score card against deploy lag, polish styling, expose physics problems
Bug fix (TypeError on Streamlit Cloud) - The new pre_polish_loss kwarg added to render_score_card last release raised TypeError on Cloud during the window between git push and Cloud's bytecode-cache refresh (the deployed _common.py was on the previous signature). To make the wire format resilient: * pre_polish_loss is now carried inside the score dict (just like every other field), so out-of-sync deployments never crash. * render_score_card grew a **_extra catch-all so future kwargs cannot break callers either. * format strings are guarded by isinstance(..., int | float) so a stray Tensor / None can no longer trip f"{x:.4g}". UI polish - Score card got softer radii, a radial-gradient highlight, a subtle drop shadow, tabular numerals for the loss line, and a dedicated green "polish improved by Δ" badge style (one rule for light, one for dark theme). - New helper render_config_chips() draws a chip-style row of (problem, size, device, seed, plus optional hyper-params). The Solve page uses one chip row at the top (problem identity) and a second one right above the Run button (active hyper-params, including polish/warm-start state) so the user can verify the full configuration at a glance. - Run button is now type=primary + width="stretch" so it stops competing with the Apply-preset button for visual weight. - Sidebar references row gained a "Cite (Zenodo DOI)" link. Physics catalog (PSpinGlass + RandomFieldIsing) - Both problems are reachable from the Home Physics/spin family selector with appropriate auxiliary sliders (p-order for PSpin; dim, J, σ_h for RFIM). - _solution_viz reuses render_sk for PSpin (no useful lattice) and render_ea for RFIM (same hyper-cubic geometry). Tests cover the catalog visibility (AppTest sweeps the family selector) and an end-to-end Solve flow on min_dominating_set / maxcut. Made-with: Cursor
1 parent 790df7c commit dd0cd5f

5 files changed

Lines changed: 198 additions & 41 deletions

File tree

app/_common.py

Lines changed: 133 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -450,12 +450,16 @@ def apply_theme() -> None:
450450
box-shadow: 0 1px 2px rgba(15,23,42,0.04);
451451
}}
452452
.qqa-score {{
453-
background: linear-gradient(135deg, rgba(15,118,110,0.06), rgba(30,58,138,0.06));
453+
background:
454+
radial-gradient(120% 150% at 0% 0%, rgba(56,189,248,0.10) 0%, transparent 60%),
455+
linear-gradient(135deg, rgba(15,118,110,0.06), rgba(30,58,138,0.06));
454456
border: 1px solid var(--qqa-accent);
455-
border-left: 4px solid var(--qqa-accent);
456-
padding: 1rem 1.4rem;
457-
border-radius: 8px;
457+
border-left: 5px solid var(--qqa-accent);
458+
padding: 1.1rem 1.4rem;
459+
border-radius: 14px;
458460
margin: 0.6rem 0 1rem 0;
461+
box-shadow: 0 6px 24px -12px rgba(56,189,248,0.32),
462+
0 1px 2px rgba(15,23,42,0.06);
459463
}}
460464
.qqa-score .label {{
461465
font-size: 0.72rem;
@@ -465,10 +469,11 @@ def apply_theme() -> None:
465469
}}
466470
.qqa-score .value {{
467471
font-family: 'Source Serif 4', Georgia, serif;
468-
font-size: 2.2rem;
472+
font-size: 2.4rem;
469473
font-weight: 700;
470474
color: var(--qqa-text);
471475
line-height: 1.1;
476+
letter-spacing: -0.01em;
472477
}}
473478
.qqa-score .value.infeasible {{ color: #b45309; }}
474479
.qqa-score .unit {{
@@ -481,7 +486,14 @@ def apply_theme() -> None:
481486
color: var(--qqa-muted);
482487
font-size: 0.85rem;
483488
margin-top: 0.3rem;
489+
font-variant-numeric: tabular-nums;
484490
}}
491+
.qqa-score .raw.polish {{
492+
color: #047857;
493+
font-weight: 500;
494+
margin-top: 0.45rem;
495+
}}
496+
.qqa-score .raw .muted {{ color: var(--qqa-muted); font-weight: 400; }}
485497
.qqa-badge {{
486498
display: inline-block;
487499
padding: 0.12rem 0.55rem;
@@ -661,20 +673,25 @@ def apply_theme() -> None:
661673
backdrop-filter: blur(10px);
662674
}}
663675
.qqa-score {{
664-
background: linear-gradient(135deg, rgba(56,189,248,0.14), rgba(168,85,247,0.14));
676+
background:
677+
radial-gradient(120% 150% at 0% 0%, rgba(56,189,248,0.18) 0%, transparent 60%),
678+
linear-gradient(135deg, rgba(56,189,248,0.14), rgba(168,85,247,0.14));
665679
border: 1px solid rgba(56,189,248,0.4);
666-
border-left: 4px solid var(--qqa-accent);
667-
padding: 1rem 1.4rem;
668-
border-radius: 12px;
680+
border-left: 5px solid var(--qqa-accent);
681+
padding: 1.1rem 1.4rem;
682+
border-radius: 14px;
669683
margin: 0.6rem 0 1rem 0;
684+
box-shadow: 0 14px 36px -16px rgba(56,189,248,0.50),
685+
0 1px 2px rgba(0,0,0,0.30);
670686
}}
671687
.qqa-score .label {{
672688
font-size: 0.72rem; letter-spacing: 0.12em;
673689
text-transform: uppercase; color: var(--qqa-muted);
674690
}}
675691
.qqa-score .value {{
676692
font-family: 'Source Serif 4', serif;
677-
font-size: 2.2rem; font-weight: 700; color: #f8fafc;
693+
font-size: 2.4rem; font-weight: 700; color: #f8fafc;
694+
letter-spacing: -0.01em;
678695
}}
679696
.qqa-score .value.infeasible {{ color: #fcd34d; }}
680697
.qqa-score .unit {{
@@ -683,7 +700,14 @@ def apply_theme() -> None:
683700
}}
684701
.qqa-score .raw {{
685702
color: var(--qqa-muted); font-size: 0.85rem; margin-top: 0.3rem;
703+
font-variant-numeric: tabular-nums;
686704
}}
705+
.qqa-score .raw.polish {{
706+
color: #6ee7b7;
707+
font-weight: 500;
708+
margin-top: 0.45rem;
709+
}}
710+
.qqa-score .raw .muted {{ color: var(--qqa-muted); font-weight: 400; }}
687711
.qqa-badge {{
688712
display: inline-block; padding: 0.12rem 0.55rem; border-radius: 999px;
689713
font-size: 0.72rem; font-weight: 600; letter-spacing: 0.04em;
@@ -705,6 +729,7 @@ def apply_theme() -> None:
705729
_PAPER_URL = "https://openreview.net/forum?id=9EfBeXaXf0"
706730
_GITHUB_URL = "https://github.com/Yuma-Ichikawa/QQA4CO"
707731
_DEMO_URL = "https://parallelquasiquantum4co.streamlit.app/"
732+
_DOI_URL = "https://doi.org/10.5281/zenodo.19648231"
708733

709734

710735
def sidebar_brand() -> None:
@@ -851,6 +876,8 @@ def paper_link_footer() -> None:
851876
text-decoration:none;">📄 Paper (OpenReview)</a>
852877
<a href="{_GITHUB_URL}" target="_blank" style="color:{link};
853878
text-decoration:none;">⎇ GitHub repository</a>
879+
<a href="{_DOI_URL}" target="_blank" style="color:{link};
880+
text-decoration:none;">🆔 Cite (Zenodo DOI)</a>
854881
<a href="{_DEMO_URL}" target="_blank" style="color:{link};
855882
text-decoration:none;">🚀 Hosted live demo</a>
856883
</div>
@@ -893,51 +920,111 @@ def hero_badges() -> None:
893920
)
894921

895922

923+
def render_config_chips(
924+
cfg: dict,
925+
*,
926+
extras: dict | None = None,
927+
) -> None:
928+
"""Render the active problem configuration as a row of chip-style badges.
929+
930+
A more polished replacement for the bare ``problem: x | size: y | ...``
931+
caption used at the top of the Solve / Visualize / Compare pages.
932+
933+
Args:
934+
cfg: Problem configuration dict, must contain ``kind`` / ``size`` /
935+
``device`` / ``seed``.
936+
extras: Optional ``{label: value}`` dict appended after the standard
937+
chips (e.g. ``{"polish": "on", "warm-start": "off"}``).
938+
"""
939+
p = palette()
940+
accent = p["accent"]
941+
border = p["border"]
942+
muted = p["muted"]
943+
chip_style = (
944+
"display:inline-flex;align-items:center;gap:0.35rem;"
945+
f"padding:0.22rem 0.7rem;border:1px solid {border};"
946+
"background:rgba(148,163,184,0.10);border-radius:999px;"
947+
"font-size:0.78rem;font-variant-numeric:tabular-nums;"
948+
"white-space:nowrap;"
949+
)
950+
label_style = f"color:{muted};text-transform:uppercase;letter-spacing:0.06em;font-size:0.66rem;"
951+
val_style = f"color:{accent};font-weight:600;"
952+
953+
def _chip(label: str, value: object) -> str:
954+
return (
955+
f'<span style="{chip_style}">'
956+
f'<span style="{label_style}">{label}</span>'
957+
f'<span style="{val_style}">{value}</span>'
958+
f"</span>"
959+
)
960+
961+
chips = [
962+
_chip("problem", cfg.get("kind", "?")),
963+
_chip("size", cfg.get("size", "?")),
964+
_chip("device", cfg.get("device", "?")),
965+
_chip("seed", cfg.get("seed", "?")),
966+
]
967+
if extras:
968+
for k, v in extras.items():
969+
chips.append(_chip(k, v))
970+
st.markdown(
971+
'<div style="display:flex;gap:0.45rem;flex-wrap:wrap;margin:0.2rem 0 0.9rem 0;">'
972+
+ "".join(chips)
973+
+ "</div>",
974+
unsafe_allow_html=True,
975+
)
976+
977+
896978
def render_score_card(
897979
score: dict,
898980
raw_loss: float | None = None,
899-
*,
900-
pre_polish_loss: float | None = None,
981+
**_extra: object,
901982
) -> None:
902983
"""Render the big problem-specific score tile used by the Solve page.
903984
904985
Parameters
905986
----------
906987
score:
907-
Output of ``problem.score_summary``.
988+
Output of ``problem.score_summary``. The dict may carry an extra
989+
``pre_polish_loss`` key — when present *and* strictly worse than
990+
``raw_loss`` we surface a small "before polish" badge so users
991+
can see how much :func:`qqa.polish.greedy_one_flip` contributed.
908992
raw_loss:
909993
Optional raw ``loss_fn`` value (after polish, since ``anneal``
910994
replaces ``best_obj`` with the polished value).
911-
pre_polish_loss:
912-
Optional ``loss_fn`` value of the *un-polished* annealer winner.
913-
Displayed as a small "before polish" badge whenever it is
914-
strictly worse than ``raw_loss`` so the user can see how much
915-
:func:`qqa.polish.greedy_one_flip` contributed.
995+
**_extra:
996+
Forward-compatible: silently absorbs any future kwargs from
997+
callers so an out-of-sync deployment never crashes the page
998+
with ``TypeError: got an unexpected keyword argument``.
916999
"""
917-
if not score:
1000+
if not isinstance(score, dict) or not score:
9181001
return
919-
feas = score.get("feasible", True)
1002+
feas = bool(score.get("feasible", True))
9201003
badge = (
9211004
'<span class="qqa-badge ok">feasible</span>'
9221005
if feas
9231006
else '<span class="qqa-badge warn">infeasible</span>'
9241007
)
9251008
value = score.get("value", "-")
926-
value_s = f"{value:.4g}" if isinstance(value, float) else str(value)
1009+
value_s = f"{value:.4g}" if isinstance(value, int | float) else str(value)
9271010
unit = score.get("unit", "")
9281011
unit_html = f'<span class="unit">{unit}</span>' if unit else ""
929-
raw_html = f'<div class="raw">raw loss = {raw_loss:.4g}</div>' if raw_loss is not None else ""
1012+
raw_html = ""
1013+
if isinstance(raw_loss, int | float):
1014+
raw_html = f'<div class="raw">raw loss = {float(raw_loss):.4g}</div>'
9301015
polish_html = ""
1016+
pre_polish = score.get("pre_polish_loss")
9311017
if (
932-
pre_polish_loss is not None
933-
and raw_loss is not None
1018+
isinstance(pre_polish, int | float)
1019+
and isinstance(raw_loss, int | float)
9341020
# Only surface the line when polish actually moved the needle.
935-
and pre_polish_loss > raw_loss + 1e-9
1021+
and float(pre_polish) > float(raw_loss) + 1e-9
9361022
):
937-
delta = pre_polish_loss - raw_loss
1023+
delta = float(pre_polish) - float(raw_loss)
9381024
polish_html = (
939-
f'<div class="raw">pre-polish loss = {pre_polish_loss:.4g} '
940-
f"(polish improved by {delta:.4g})</div>"
1025+
f'<div class="raw polish">▴ polish improved by '
1026+
f"<b>{delta:.4g}</b> "
1027+
f'<span class="muted">(pre-polish = {float(pre_polish):.4g})</span></div>'
9411028
)
9421029
value_cls = "value" if feas else "value infeasible"
9431030
st.markdown(
@@ -1006,6 +1093,24 @@ def build_problem(cfg: dict) -> Any:
10061093
)
10071094
if kind == "sk":
10081095
return _safe_call(qqa.SherringtonKirkpatrick, N=size, seed=seed, device=device)
1096+
if kind == "pspin":
1097+
return _safe_call(
1098+
qqa.PSpinGlass,
1099+
N=size,
1100+
p=int(extra.get("p_order", 3)),
1101+
seed=seed,
1102+
device=device,
1103+
)
1104+
if kind == "rfim":
1105+
return _safe_call(
1106+
qqa.RandomFieldIsing,
1107+
L=size,
1108+
dim=int(extra.get("dim", 2)),
1109+
J=float(extra.get("coupling_J", 1.0)),
1110+
h_std=float(extra.get("h_std", 1.0)),
1111+
seed=seed,
1112+
device=device,
1113+
)
10091114
if kind == "perceptron":
10101115
return _safe_call(
10111116
qqa.BinaryPerceptron,

app/_solution_viz.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1422,7 +1422,14 @@ def render_hopfield(problem, result, cfg) -> None:
14221422
"maxsat3": render_maxsat3,
14231423
"ising1d": render_ising1d,
14241424
"sk": render_sk,
1425+
# Dense p-spin glass has no useful spatial layout — reuse the SK ring
1426+
# + local-energy view (J is None for p > 2 so only the ring renders,
1427+
# which is exactly what we want).
1428+
"pspin": render_sk,
14251429
"ea": render_ea,
1430+
# RFIM lives on the same hyper-cubic lattice as EA, so the EA
1431+
# 2D-slice renderer applies as-is.
1432+
"rfim": render_ea,
14261433
"perceptron": render_perceptron,
14271434
"hopfield": render_hopfield,
14281435
}

app/pages/1_Solve.py

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
palette,
2020
paper_link_footer,
2121
plotly_layout,
22+
render_config_chips,
2223
render_score_card,
2324
sidebar_brand,
2425
theme_toggle_in_sidebar,
@@ -49,10 +50,7 @@
4950
)
5051
st.stop()
5152
cfg = st.session_state["problem_config"]
52-
st.caption(
53-
f"problem: **{cfg['kind']}** | size: **{cfg['size']}** | "
54-
f"device: **{cfg['device']}** | seed: **{cfg['seed']}**"
55-
)
53+
render_config_chips(cfg)
5654

5755
# Hyper-parameter presets — each tuple is
5856
# (sol_size, epochs, learning_rate, temp, min_bg, max_bg, curve_rate,
@@ -447,7 +445,21 @@ def on_epoch_end(self, state: CallbackState) -> None:
447445
)
448446

449447

450-
run = st.button("▶ Run QQA", type="primary")
448+
# Compact "active hyper-params" chip row, so the user can see what is
449+
# about to run without scrolling the sidebar.
450+
render_config_chips(
451+
cfg,
452+
extras={
453+
"sol_size": sol_size,
454+
"epochs": epochs,
455+
"lr": f"{learning_rate:.2g}",
456+
"T": f"{temp:.2g}",
457+
"polish": "on" if polish else "off",
458+
"warm-start": "on" if warm_start else "off",
459+
},
460+
)
461+
462+
run = st.button("▶ Run QQA", type="primary", width="stretch")
451463
if run:
452464
try:
453465
problem = build_problem(cfg)
@@ -521,13 +533,19 @@ def on_epoch_end(self, state: CallbackState) -> None:
521533
else float(np.asarray(result.best_obj).mean())
522534
)
523535
# The callback tracks the *un-polished* running best (it fires inside
524-
# the annealing loop, before greedy_one_flip runs). Surface that here
525-
# so users can see how much polish contributed when it actually fired.
526-
pre_polish = (
527-
cb.best_disc[-1] if (polish and result.polished_sol is not None and cb.best_disc) else None
528-
)
536+
# the annealing loop, before greedy_one_flip runs). Inject it into
537+
# the score dict so render_score_card can show a "polish improved
538+
# by Δ" badge when polish actually moved the needle. We pass it via
539+
# the dict (instead of a kwarg) so an older deployed _common.py
540+
# cannot raise ``TypeError: got an unexpected keyword argument``.
541+
import contextlib # noqa: PLC0415
542+
543+
score_payload = dict(result.score) if isinstance(result.score, dict) else {}
544+
if polish and result.polished_sol is not None and cb.best_disc:
545+
with contextlib.suppress(TypeError, ValueError):
546+
score_payload["pre_polish_loss"] = float(cb.best_disc[-1])
529547
with score_holder.container():
530-
render_score_card(result.score, raw_loss=raw, pre_polish_loss=pre_polish)
548+
render_score_card(score_payload, raw_loss=raw)
531549
st.session_state.setdefault("results", []).append(
532550
{
533551
"cfg": dict(cfg),

app/streamlit_app.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,8 @@
154154
("ising1d", "1D Ising model"),
155155
("ea", "Edwards–Anderson spin glass"),
156156
("sk", "Sherrington–Kirkpatrick spin glass"),
157+
("pspin", "p-spin glass (dense, p ≥ 2)"),
158+
("rfim", "Random Field Ising Model (RFIM)"),
157159
("perceptron", "Binary perceptron"),
158160
("hopfield", "Hopfield memory"),
159161
],
@@ -168,8 +170,12 @@
168170
format_func=lambda s: _LABELS[s],
169171
)
170172

171-
size_default = {"tsp": 8, "qap": 8, "nqueens": 8, "ea": 6}.get(problem_kind, 32)
172-
size_max = {"tsp": 20, "qap": 14, "nqueens": 14, "ea": 16}.get(problem_kind, 400)
173+
size_default = {"tsp": 8, "qap": 8, "nqueens": 8, "ea": 6, "rfim": 8, "pspin": 16}.get(
174+
problem_kind, 32
175+
)
176+
size_max = {"tsp": 20, "qap": 14, "nqueens": 14, "ea": 16, "rfim": 20, "pspin": 32}.get(
177+
problem_kind, 400
178+
)
173179
size_label = {
174180
"tsp": "Cities N",
175181
"qap": "Facilities N",
@@ -178,6 +184,8 @@
178184
"knapsack": "Items N",
179185
"number_partition": "Values N",
180186
"ea": "Lattice side L",
187+
"rfim": "Lattice side L",
188+
"pspin": "Spins N",
181189
}.get(problem_kind, "Problem size (N)")
182190
size = st.number_input(size_label, min_value=4, max_value=size_max, value=size_default)
183191
seed = st.number_input("Seed", min_value=0, max_value=10_000, value=0)
@@ -203,6 +211,12 @@
203211
)
204212
if problem_kind == "ea":
205213
extra["dim"] = st.selectbox("Lattice dim", (2, 3), index=1)
214+
if problem_kind == "rfim":
215+
extra["dim"] = st.selectbox("Lattice dim", (1, 2, 3), index=1)
216+
extra["coupling_J"] = st.slider("Ferromagnetic coupling J", 0.1, 5.0, 1.0, 0.1)
217+
extra["h_std"] = st.slider("Random-field σ_h", 0.0, 5.0, 1.0, 0.1)
218+
if problem_kind == "pspin":
219+
extra["p_order"] = st.slider("Interaction order p", 2, 5, 3)
206220
if problem_kind == "perceptron":
207221
extra["alpha"] = st.slider("Loading α = M/N", 0.1, 1.5, 0.5, 0.1)
208222
if problem_kind == "hopfield":

0 commit comments

Comments
 (0)