|
6 | 6 |
|
7 | 7 | from __future__ import annotations |
8 | 8 |
|
| 9 | +import contextlib |
9 | 10 | from typing import Any |
10 | 11 |
|
11 | 12 | import networkx as nx |
@@ -326,14 +327,22 @@ def apply_theme() -> None: |
326 | 327 | border-right: 1px solid var(--qqa-border); |
327 | 328 | }} |
328 | 329 | /* 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). */ |
330 | 333 | [data-testid="stSidebarNav"]::before {{ display: none; }} |
331 | 334 | section[data-testid="stSidebar"] [data-testid="stSidebarNav"] > div:first-child, |
332 | 335 | section[data-testid="stSidebar"] [data-testid="stSidebarNav"] > ul + div:has(>h1), |
333 | 336 | section[data-testid="stSidebar"] [data-testid="stSidebarNav"] h1:first-of-type, |
334 | 337 | section[data-testid="stSidebar"] [data-testid="stSidebarNav"] h2:first-of-type {{ |
335 | 338 | display: none !important; |
336 | 339 | }} |
| 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 | + }} |
337 | 346 | [data-testid="stSidebarNav"] a {{ |
338 | 347 | color: var(--qqa-text) !important; |
339 | 348 | font-weight: 500; |
@@ -555,6 +564,9 @@ def apply_theme() -> None: |
555 | 564 | }} |
556 | 565 | /* Same nav-header suppression as in light theme. */ |
557 | 566 | [data-testid="stSidebarNav"]::before {{ display: none; }} |
| 567 | + section[data-testid="stSidebar"] [data-testid="stSidebarNav"] ul li:first-child {{ |
| 568 | + display: none !important; |
| 569 | + }} |
558 | 570 | section[data-testid="stSidebar"] [data-testid="stSidebarNav"] > div:first-child, |
559 | 571 | section[data-testid="stSidebar"] [data-testid="stSidebarNav"] > ul + div:has(>h1), |
560 | 572 | section[data-testid="stSidebar"] [data-testid="stSidebarNav"] h1:first-of-type, |
@@ -749,6 +761,56 @@ def sidebar_brand() -> None: |
749 | 761 | ) |
750 | 762 |
|
751 | 763 |
|
| 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 | + |
752 | 814 | def paper_link_footer() -> None: |
753 | 815 | """Compact link row rendered at the very bottom of the sidebar.""" |
754 | 816 | theme = get_theme() |
@@ -872,98 +934,229 @@ def build_problem(cfg: dict) -> Any: |
872 | 934 | ) |
873 | 935 |
|
874 | 936 | 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) |
888 | 940 | if kind == "ising1d": |
889 | | - return qqa.Ising1D(N=size, device=device) |
| 941 | + return _safe_call(qqa.Ising1D, N=size, device=device) |
890 | 942 | 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, |
893 | 949 | ) |
894 | 950 | 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) |
896 | 952 | 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, |
899 | 959 | ) |
900 | 960 | if kind == "hopfield": |
901 | | - return qqa.HopfieldMemory( |
| 961 | + return _safe_call( |
| 962 | + qqa.HopfieldMemory, |
902 | 963 | N=size, |
903 | 964 | patterns=int(extra.get("patterns", 3)), |
904 | | - seed=cfg["seed"], |
| 965 | + seed=seed, |
905 | 966 | device=device, |
906 | 967 | ) |
907 | | - |
908 | | - # -------- new (Phase A) problems --------------------------------------- |
909 | 968 | if kind == "knapsack": |
910 | | - return qqa.Knapsack( |
| 969 | + return _safe_call( |
| 970 | + qqa.Knapsack, |
911 | 971 | N=size, |
912 | 972 | capacity_ratio=float(extra.get("capacity_ratio", 0.5)), |
913 | | - seed=cfg["seed"], |
| 973 | + seed=seed, |
914 | 974 | device=device, |
915 | 975 | ) |
916 | 976 | if kind == "number_partition": |
917 | | - return qqa.NumberPartitioning( |
| 977 | + return _safe_call( |
| 978 | + qqa.NumberPartitioning, |
918 | 979 | N=size, |
919 | 980 | 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, |
937 | 982 | device=device, |
938 | 983 | ) |
939 | 984 | if kind == "maxsat3": |
940 | | - return qqa.MaxSAT3( |
| 985 | + return _safe_call( |
| 986 | + qqa.MaxSAT3, |
941 | 987 | N=size, |
942 | 988 | ratio=float(extra.get("ratio", 3.0)), |
943 | | - seed=cfg["seed"], |
| 989 | + seed=seed, |
944 | 990 | device=device, |
945 | 991 | ) |
946 | 992 | 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, |
948 | 998 | 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, |
952 | 1000 | device=device, |
| 1001 | + **_extract_penalty_kwargs(extra, defaults={"row_penalty": 5.0, "col_penalty": 5.0}), |
953 | 1002 | ) |
954 | 1003 | if kind == "qap": |
955 | | - return qqa.QAP( |
| 1004 | + return _safe_call( |
| 1005 | + qqa.QAP, |
956 | 1006 | N=size, |
957 | | - column_penalty=float(extra.get("column_penalty", 10.0)), |
958 | | - seed=cfg["seed"], |
| 1007 | + seed=seed, |
959 | 1008 | device=device, |
| 1009 | + **_extract_penalty_kwargs(extra, defaults={"column_penalty": 10.0}), |
960 | 1010 | ) |
961 | 1011 | if kind == "nqueens": |
962 | | - return qqa.NQueens(N=size, device=device) |
| 1012 | + return _safe_call(qqa.NQueens, N=size, device=device) |
963 | 1013 |
|
964 | 1014 | raise ValueError(f"Unknown problem kind {kind!r}") |
965 | 1015 |
|
966 | 1016 |
|
| 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 | + |
967 | 1160 | # --------------------------------------------------------------------------- |
968 | 1161 | # Previews |
969 | 1162 | # --------------------------------------------------------------------------- |
|
0 commit comments