Skip to content

Commit b519ba2

Browse files
author
Yuma Ichikawa
committed
feat(app,qqa): 3D EA cone field, TSP multi-penalty UX, kwargs-resilient build_problem
* App: render 3D Edwards-Anderson as a rotatable Plotly Cone field (one tail-anchored cone per spin), with a single None-delimited Scatter3d polyline for nearest-neighbour bonds and an optional frustration overlay coloured by local energy. The z-slice carousel is demoted to a collapsible expander. * App: add `_safe_call(cls, **kwargs)` helper in app/_common.py and route every `build_problem` branch through it. Unknown kwargs in `st.session_state['problem_config']['extra']` (e.g. left over from an old TSP schema) now produce a non-blocking caption instead of crashing the page. * App: TSP slider UX redesigned around the multi-penalty story — "Sync λ_r = λ_c" toggle gives a single shared λ for the common case, with separate sliders behind the toggle for asymmetric setups. Caption explains that TSP is solved with the penalty method. * App: hide the auto-generated "streamlit app" entry-page link in the sidebar nav (both light and dark themes); the brand block above is the home anchor. * App: branded empty-state cards on Solve / Visualize / Compare with a primary CTA via `st.page_link` — no more bare warnings. * qqa.TSP / qqa.QAP: accept a structured `penalty_weights` dict in addition to scalar penalty kwargs. Selection precedence is defaults < legacy alias < explicit scalars < `penalty_weights`. TSP keeps `column_penalty=None` as a deprecated alias that maps to both row and col penalties (DeprecationWarning). * tests: 6 new tests covering the safe-kwargs filter, legacy alias, dict override, modern-kwargs precedence, QAP penalty dict, and the 3D EA Cone trace. `test_visualize_page_handles_missing_run` updated to look for the empty-state markdown card. Full suite: 97 passed, 1 skipped.
1 parent 795450d commit b519ba2

9 files changed

Lines changed: 788 additions & 109 deletions

File tree

app/_common.py

Lines changed: 247 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from __future__ import annotations
88

9+
import contextlib
910
from typing import Any
1011

1112
import networkx as nx
@@ -326,14 +327,22 @@ def apply_theme() -> None:
326327
border-right: 1px solid var(--qqa-border);
327328
}}
328329
/* Hide the auto-generated multipage heading ("streamlit app")
329-
rendered above our brand block. */
330+
rendered above our brand block, AND hide the entry-page
331+
navigation link itself (the brand logo above is the home
332+
anchor; an extra "streamlit app" link is just noise). */
330333
[data-testid="stSidebarNav"]::before {{ display: none; }}
331334
section[data-testid="stSidebar"] [data-testid="stSidebarNav"] > div:first-child,
332335
section[data-testid="stSidebar"] [data-testid="stSidebarNav"] > ul + div:has(>h1),
333336
section[data-testid="stSidebar"] [data-testid="stSidebarNav"] h1:first-of-type,
334337
section[data-testid="stSidebar"] [data-testid="stSidebarNav"] h2:first-of-type {{
335338
display: none !important;
336339
}}
340+
/* Drop the first <li> in the nav list — that is the entry page
341+
link Streamlit derives from the file name (here: "streamlit
342+
app"). The brand block + the page list under it is enough. */
343+
section[data-testid="stSidebar"] [data-testid="stSidebarNav"] ul li:first-child {{
344+
display: none !important;
345+
}}
337346
[data-testid="stSidebarNav"] a {{
338347
color: var(--qqa-text) !important;
339348
font-weight: 500;
@@ -555,6 +564,9 @@ def apply_theme() -> None:
555564
}}
556565
/* Same nav-header suppression as in light theme. */
557566
[data-testid="stSidebarNav"]::before {{ display: none; }}
567+
section[data-testid="stSidebar"] [data-testid="stSidebarNav"] ul li:first-child {{
568+
display: none !important;
569+
}}
558570
section[data-testid="stSidebar"] [data-testid="stSidebarNav"] > div:first-child,
559571
section[data-testid="stSidebar"] [data-testid="stSidebarNav"] > ul + div:has(>h1),
560572
section[data-testid="stSidebar"] [data-testid="stSidebarNav"] h1:first-of-type,
@@ -749,6 +761,56 @@ def sidebar_brand() -> None:
749761
)
750762

751763

764+
def empty_state_card(
765+
*,
766+
title: str,
767+
body: str,
768+
cta_label: str = "Open Solve",
769+
cta_page: str = "pages/1_Solve.py",
770+
) -> None:
771+
"""Branded empty-state card with a primary CTA.
772+
773+
Used on Visualize / Compare when no run is yet available, so the
774+
user sees a deliberate path forward instead of a bare warning row.
775+
Falls back gracefully to a markdown-only card if the running
776+
Streamlit predates ``st.page_link``.
777+
"""
778+
theme = get_theme()
779+
p = palette()
780+
accent = "#0f766e" if theme == "light" else "#38bdf8"
781+
muted = "#64748b" if theme == "light" else "#94a3b8"
782+
st.markdown(
783+
f"""
784+
<div style="
785+
border:1px solid {p["border"]};
786+
background:{p["bg_card"]};
787+
padding:1.4rem 1.6rem;
788+
border-radius:12px;
789+
box-shadow:0 1px 3px rgba(15,23,42,0.05);
790+
">
791+
<div style="
792+
font-family:'Source Serif 4',Georgia,serif;
793+
font-weight:600;font-size:1.15rem;color:{p["text"]};
794+
margin-bottom:0.45rem;
795+
">{title}</div>
796+
<div style="font-size:0.95rem;color:{muted};line-height:1.45;">
797+
{body}
798+
</div>
799+
<div style="height:0.85rem;"></div>
800+
<div style="
801+
display:inline-block;padding:0.35rem 0.85rem;border-radius:8px;
802+
border:1px solid {accent};color:{accent};font-weight:500;
803+
font-size:0.9rem;
804+
">↳ {cta_label}</div>
805+
</div>
806+
""",
807+
unsafe_allow_html=True,
808+
)
809+
if hasattr(st, "page_link"):
810+
with contextlib.suppress(Exception):
811+
st.page_link(cta_page, label=cta_label, icon="▶")
812+
813+
752814
def paper_link_footer() -> None:
753815
"""Compact link row rendered at the very bottom of the sidebar."""
754816
theme = get_theme()
@@ -872,98 +934,229 @@ def build_problem(cfg: dict) -> Any:
872934
)
873935

874936
size = int(cfg["size"])
875-
if kind in {"mis", "maxcut", "maxclique", "coloring"}:
876-
d = extra.get("graph_d", 3)
877-
if (size * d) % 2 != 0:
878-
d = max(2, d - 1) if d > 2 else d + 1
879-
g = nx.random_regular_graph(d=d, n=size, seed=cfg["seed"])
880-
if kind == "mis":
881-
return qqa.MaximumIndependentSet(g, device=device)
882-
if kind == "maxcut":
883-
return qqa.MaxCut(g, device=device)
884-
if kind == "maxclique":
885-
return qqa.MaxClique(g, device=device)
886-
if kind == "coloring":
887-
return qqa.Coloring(g, num_category=extra.get("num_category", 3), device=device)
937+
seed = cfg["seed"]
938+
if kind in {"mis", "maxcut", "maxclique", "coloring", "vertex_cover", "graph_bisection"}:
939+
return _build_graph_problem(kind, size, seed, device, extra)
888940
if kind == "ising1d":
889-
return qqa.Ising1D(N=size, device=device)
941+
return _safe_call(qqa.Ising1D, N=size, device=device)
890942
if kind == "ea":
891-
return qqa.EdwardsAnderson(
892-
L=size, dim=int(extra.get("dim", 3)), seed=cfg["seed"], device=device
943+
return _safe_call(
944+
qqa.EdwardsAnderson,
945+
L=size,
946+
dim=int(extra.get("dim", 3)),
947+
seed=seed,
948+
device=device,
893949
)
894950
if kind == "sk":
895-
return qqa.SherringtonKirkpatrick(N=size, seed=cfg["seed"], device=device)
951+
return _safe_call(qqa.SherringtonKirkpatrick, N=size, seed=seed, device=device)
896952
if kind == "perceptron":
897-
return qqa.BinaryPerceptron(
898-
N=size, alpha=float(extra.get("alpha", 0.5)), seed=cfg["seed"], device=device
953+
return _safe_call(
954+
qqa.BinaryPerceptron,
955+
N=size,
956+
alpha=float(extra.get("alpha", 0.5)),
957+
seed=seed,
958+
device=device,
899959
)
900960
if kind == "hopfield":
901-
return qqa.HopfieldMemory(
961+
return _safe_call(
962+
qqa.HopfieldMemory,
902963
N=size,
903964
patterns=int(extra.get("patterns", 3)),
904-
seed=cfg["seed"],
965+
seed=seed,
905966
device=device,
906967
)
907-
908-
# -------- new (Phase A) problems ---------------------------------------
909968
if kind == "knapsack":
910-
return qqa.Knapsack(
969+
return _safe_call(
970+
qqa.Knapsack,
911971
N=size,
912972
capacity_ratio=float(extra.get("capacity_ratio", 0.5)),
913-
seed=cfg["seed"],
973+
seed=seed,
914974
device=device,
915975
)
916976
if kind == "number_partition":
917-
return qqa.NumberPartitioning(
977+
return _safe_call(
978+
qqa.NumberPartitioning,
918979
N=size,
919980
max_value=int(extra.get("max_value", 100)),
920-
seed=cfg["seed"],
921-
device=device,
922-
)
923-
if kind == "vertex_cover":
924-
d = extra.get("graph_d", 3)
925-
if (size * d) % 2 != 0:
926-
d = max(2, d - 1) if d > 2 else d + 1
927-
g = nx.random_regular_graph(d=d, n=size, seed=cfg["seed"])
928-
return qqa.VertexCover(g, device=device)
929-
if kind == "graph_bisection":
930-
d = extra.get("graph_d", 3)
931-
if (size * d) % 2 != 0:
932-
d = max(2, d - 1) if d > 2 else d + 1
933-
g = nx.random_regular_graph(d=d, n=size, seed=cfg["seed"])
934-
return qqa.GraphBisection(
935-
g,
936-
balance_penalty=float(extra.get("balance_penalty", 2.0)),
981+
seed=seed,
937982
device=device,
938983
)
939984
if kind == "maxsat3":
940-
return qqa.MaxSAT3(
985+
return _safe_call(
986+
qqa.MaxSAT3,
941987
N=size,
942988
ratio=float(extra.get("ratio", 3.0)),
943-
seed=cfg["seed"],
989+
seed=seed,
944990
device=device,
945991
)
946992
if kind == "tsp":
947-
return qqa.TSP(
993+
# Multi-penalty problem: forward every penalty-shaped key from
994+
# ``extra`` so the dashboard can declare an arbitrary number of
995+
# penalty terms without touching this dispatcher.
996+
return _safe_call(
997+
qqa.TSP,
948998
N=size,
949-
row_penalty=float(extra.get("row_penalty", 5.0)),
950-
col_penalty=float(extra.get("col_penalty", 5.0)),
951-
seed=cfg["seed"],
999+
seed=seed,
9521000
device=device,
1001+
**_extract_penalty_kwargs(extra, defaults={"row_penalty": 5.0, "col_penalty": 5.0}),
9531002
)
9541003
if kind == "qap":
955-
return qqa.QAP(
1004+
return _safe_call(
1005+
qqa.QAP,
9561006
N=size,
957-
column_penalty=float(extra.get("column_penalty", 10.0)),
958-
seed=cfg["seed"],
1007+
seed=seed,
9591008
device=device,
1009+
**_extract_penalty_kwargs(extra, defaults={"column_penalty": 10.0}),
9601010
)
9611011
if kind == "nqueens":
962-
return qqa.NQueens(N=size, device=device)
1012+
return _safe_call(qqa.NQueens, N=size, device=device)
9631013

9641014
raise ValueError(f"Unknown problem kind {kind!r}")
9651015

9661016

1017+
def _build_graph_problem(kind: str, size: int, seed: int, device: str, extra: dict):
1018+
"""Random-regular-graph problems share a common preamble (degree
1019+
sanitisation + ``nx.random_regular_graph``), so factor it out."""
1020+
d = extra.get("graph_d", 3)
1021+
if (size * d) % 2 != 0:
1022+
d = max(2, d - 1) if d > 2 else d + 1
1023+
g = nx.random_regular_graph(d=d, n=size, seed=seed)
1024+
if kind == "mis":
1025+
return _safe_call(qqa.MaximumIndependentSet, g, device=device)
1026+
if kind == "maxcut":
1027+
return _safe_call(qqa.MaxCut, g, device=device)
1028+
if kind == "maxclique":
1029+
return _safe_call(qqa.MaxClique, g, device=device)
1030+
if kind == "coloring":
1031+
return _safe_call(qqa.Coloring, g, num_category=extra.get("num_category", 3), device=device)
1032+
if kind == "vertex_cover":
1033+
return _safe_call(qqa.VertexCover, g, device=device)
1034+
if kind == "graph_bisection":
1035+
return _safe_call(
1036+
qqa.GraphBisection,
1037+
g,
1038+
balance_penalty=float(extra.get("balance_penalty", 2.0)),
1039+
device=device,
1040+
)
1041+
raise ValueError(f"Unknown graph-problem kind {kind!r}")
1042+
1043+
1044+
# ---------------------------------------------------------------------------
1045+
# Constructor dispatch helpers — keep ``build_problem`` and the saved-config
1046+
# format decoupled from individual class signatures.
1047+
# ---------------------------------------------------------------------------
1048+
1049+
1050+
def _safe_call(cls, *args, **kwargs):
1051+
"""Invoke ``cls(*args, **kwargs)`` but silently drop any keyword that
1052+
its ``__init__`` does not accept.
1053+
1054+
Why: the Streamlit ``problem_config`` dict is persisted across reruns
1055+
via ``st.session_state``. If a problem class evolves (e.g. TSP renames
1056+
``column_penalty`` → ``row_penalty``/``col_penalty``) the next page
1057+
refresh would otherwise crash because the old key is still in
1058+
``extra``. Filtering against the constructor signature on the
1059+
receiving end makes the call boundary forward- and backward-compatible
1060+
by construction.
1061+
"""
1062+
import inspect # noqa: PLC0415 - lazy: only needed here
1063+
1064+
try:
1065+
sig = inspect.signature(cls.__init__)
1066+
except (TypeError, ValueError):
1067+
return cls(*args, **kwargs)
1068+
1069+
accepts_var_kw = any(p.kind is p.VAR_KEYWORD for p in sig.parameters.values())
1070+
if accepts_var_kw:
1071+
return cls(*args, **kwargs)
1072+
1073+
accepted = set(sig.parameters)
1074+
accepted.discard("self")
1075+
safe = {k: v for k, v in kwargs.items() if k in accepted}
1076+
dropped = set(kwargs) - set(safe)
1077+
if dropped:
1078+
# Surface the drop in the UI without crashing the page. Streamlit
1079+
# may not be initialised in test contexts, so guard the call.
1080+
try:
1081+
import streamlit as _st # noqa: PLC0415
1082+
1083+
_st.caption(
1084+
f"Note — dropped unknown {cls.__name__} kwargs from session "
1085+
f"state: {sorted(dropped)} (signature has changed)."
1086+
)
1087+
except Exception:
1088+
pass
1089+
return cls(*args, **safe)
1090+
1091+
1092+
# Recognised penalty-coefficient suffixes / aliases. Adding a new
1093+
# penalty term to a problem only requires (a) adding a new keyword to the
1094+
# class' ``__init__`` and (b) declaring the slider in
1095+
# ``streamlit_app.py`` — this dispatcher does **not** need to change.
1096+
_PENALTY_SUFFIXES: tuple[str, ...] = ("_penalty", "_weight", "_lambda")
1097+
# Legacy keys → modern keys. Two purposes:
1098+
# * keep saved configs working after a rename;
1099+
# * let users typing `column_penalty` (the old name) still get the
1100+
# intended behaviour (mapped to row + col).
1101+
_PENALTY_ALIASES: dict[str, tuple[str, ...]] = {
1102+
"column_penalty": ("row_penalty", "col_penalty"),
1103+
}
1104+
1105+
1106+
def _extract_penalty_kwargs(extra: dict, *, defaults: dict[str, float]) -> dict[str, float]:
1107+
"""Return a dict of penalty-shaped kwargs, merging ``defaults``,
1108+
explicit ``extra`` keys, dict-form ``penalty_weights``, and legacy
1109+
aliases. Numeric values are coerced to ``float`` so torch is happy.
1110+
1111+
Selection rules (later overrides earlier):
1112+
1. ``defaults`` (lowest priority)
1113+
2. legacy aliases in ``extra`` (e.g. ``column_penalty`` mapped to
1114+
both ``row_penalty`` and ``col_penalty``)
1115+
3. explicit penalty-suffixed keys in ``extra`` (override legacy)
1116+
4. ``extra['penalty_weights']`` dict (most explicit ⇒ wins)
1117+
1118+
To avoid a stale legacy key drowning a fresh modern key, the legacy
1119+
alias itself is **never** propagated to the output dict; only its
1120+
modern translations are.
1121+
"""
1122+
out: dict[str, float] = dict(defaults)
1123+
legacy_targets: set[str] = set()
1124+
for modern_keys in _PENALTY_ALIASES.values():
1125+
legacy_targets.update(modern_keys)
1126+
1127+
# 2. legacy aliases (translate, do not propagate the legacy key itself).
1128+
for legacy, modern_keys in _PENALTY_ALIASES.items():
1129+
if legacy in extra:
1130+
try:
1131+
v = float(extra[legacy])
1132+
except (TypeError, ValueError):
1133+
continue
1134+
for k in modern_keys:
1135+
out[k] = v
1136+
1137+
# 3. explicit penalty-shaped keys override legacy translations.
1138+
for k, v in extra.items():
1139+
if k in _PENALTY_ALIASES:
1140+
continue # already handled in step 2
1141+
if any(k.endswith(suf) for suf in _PENALTY_SUFFIXES):
1142+
try:
1143+
out[k] = float(v)
1144+
except (TypeError, ValueError):
1145+
continue
1146+
1147+
# 4. structured dict overrides every preceding source.
1148+
pw = extra.get("penalty_weights")
1149+
if isinstance(pw, dict):
1150+
for k, v in pw.items():
1151+
key = k if any(k.endswith(suf) for suf in _PENALTY_SUFFIXES) else f"{k}_penalty"
1152+
try:
1153+
out[key] = float(v)
1154+
except (TypeError, ValueError):
1155+
continue
1156+
1157+
return out
1158+
1159+
9671160
# ---------------------------------------------------------------------------
9681161
# Previews
9691162
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)