Skip to content

Commit b401934

Browse files
committed
feat(ui): polish + warm-start toggles, expose new problem catalog
Solve page - New 'Post-processing & warm-start' sidebar expander with two toggles: * Greedy 1-flip polish (default ON, gracefully no-ops on Spin / Categorical problems where Q_mat is unavailable). * BFS 2-color warm-start (graph QUBOs only, falls back to random init if the problem does not expose nx_graph / graph). - Wire both into the qqa.anneal call via the new (initial_state=, polish=) parameters. - Score card now surfaces a 'pre-polish loss = ... (polish improved by Δ)' badge whenever polish actually moved the needle, so users can see the contribution at a glance. - Run history snapshots both flags so the Visualize / Compare pages see a faithful record of what produced each result. Home page - Register the new MinimumDominatingSet (Graph family) and BalancedGraphPartition (Categorical / assignment) problems so the UI catalog matches the CLI/Python API one-to-one. - bgp uses the existing graph-degree slider plus a num_category ('partitions K') and a balance-penalty slider. _common - build_problem dispatches the two new kinds. - render_score_card grows an optional pre_polish_loss kwarg. Tests - Add three AppTest regressions: the Solve sidebar must expose both toggles with the documented defaults, the Home catalog must list Minimum Dominating Set and Balanced Graph Partition, and the full Solve flow must succeed end-to-end on min_dominating_set. Made-with: Cursor
1 parent eef8902 commit b401934

4 files changed

Lines changed: 216 additions & 6 deletions

File tree

app/_common.py

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -893,8 +893,27 @@ def hero_badges() -> None:
893893
)
894894

895895

896-
def render_score_card(score: dict, raw_loss: float | None = None) -> None:
897-
"""Render the big problem-specific score tile used by the Solve page."""
896+
def render_score_card(
897+
score: dict,
898+
raw_loss: float | None = None,
899+
*,
900+
pre_polish_loss: float | None = None,
901+
) -> None:
902+
"""Render the big problem-specific score tile used by the Solve page.
903+
904+
Parameters
905+
----------
906+
score:
907+
Output of ``problem.score_summary``.
908+
raw_loss:
909+
Optional raw ``loss_fn`` value (after polish, since ``anneal``
910+
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.
916+
"""
898917
if not score:
899918
return
900919
feas = score.get("feasible", True)
@@ -908,12 +927,25 @@ def render_score_card(score: dict, raw_loss: float | None = None) -> None:
908927
unit = score.get("unit", "")
909928
unit_html = f'<span class="unit">{unit}</span>' if unit else ""
910929
raw_html = f'<div class="raw">raw loss = {raw_loss:.4g}</div>' if raw_loss is not None else ""
930+
polish_html = ""
931+
if (
932+
pre_polish_loss is not None
933+
and raw_loss is not None
934+
# Only surface the line when polish actually moved the needle.
935+
and pre_polish_loss > raw_loss + 1e-9
936+
):
937+
delta = pre_polish_loss - raw_loss
938+
polish_html = (
939+
f'<div class="raw">pre-polish loss = {pre_polish_loss:.4g} '
940+
f"(polish improved by {delta:.4g})</div>"
941+
)
911942
value_cls = "value" if feas else "value infeasible"
912943
st.markdown(
913944
f'<div class="qqa-score">'
914945
f'<div class="label">{score.get("label", "score")} · {badge}</div>'
915946
f'<div class="{value_cls}">{value_s}{unit_html}</div>'
916947
f"{raw_html}"
948+
f"{polish_html}"
917949
"</div>",
918950
unsafe_allow_html=True,
919951
)
@@ -951,7 +983,16 @@ def build_problem(cfg: dict) -> Any:
951983

952984
size = int(cfg["size"])
953985
seed = cfg["seed"]
954-
if kind in {"mis", "maxcut", "maxclique", "coloring", "vertex_cover", "graph_bisection"}:
986+
if kind in {
987+
"mis",
988+
"maxcut",
989+
"maxclique",
990+
"coloring",
991+
"vertex_cover",
992+
"graph_bisection",
993+
"min_dominating_set",
994+
"bgp",
995+
}:
955996
return _build_graph_problem(kind, size, seed, device, extra)
956997
if kind == "ising1d":
957998
return _safe_call(qqa.Ising1D, N=size, device=device)
@@ -1054,6 +1095,16 @@ def _build_graph_problem(kind: str, size: int, seed: int, device: str, extra: di
10541095
balance_penalty=float(extra.get("balance_penalty", 2.0)),
10551096
device=device,
10561097
)
1098+
if kind == "min_dominating_set":
1099+
return _safe_call(qqa.MinimumDominatingSet, g, device=device)
1100+
if kind == "bgp":
1101+
return _safe_call(
1102+
qqa.BalancedGraphPartition,
1103+
g,
1104+
num_category=int(extra.get("num_category", 3)),
1105+
penalty=float(extra.get("balance_penalty", 5e-4)),
1106+
device=device,
1107+
)
10571108
raise ValueError(f"Unknown graph-problem kind {kind!r}")
10581109

10591110

app/pages/1_Solve.py

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,36 @@ def _apply_preset(name: str) -> None:
170170
help="Repulsion strength between replicas (0 = independent runs).",
171171
)
172172

173+
with st.expander("Post-processing & warm-start", expanded=False):
174+
polish = st.toggle(
175+
"Greedy 1-flip polish (recommended)",
176+
value=st.session_state.get("polish", True),
177+
key="polish",
178+
help=(
179+
"Run a deterministic single-bit local search on the "
180+
"annealer's winner. Costs O(N · #flips) and is silently "
181+
"skipped on non-QUBO problems (Spin / Categorical). "
182+
"Typical free improvement of +10 to +90 on hard MaxCut / "
183+
"MIS / VertexCover instances."
184+
),
185+
)
186+
# Warm-start only makes sense when the problem exposes a NetworkX
187+
# graph the BFS heuristic can read. We surface the toggle for *any*
188+
# graph problem and gate the actual call on the problem object at
189+
# runtime — this keeps the sidebar layout stable across kinds.
190+
warm_start = st.toggle(
191+
"BFS 2-color warm-start (graph QUBOs only)",
192+
value=st.session_state.get("warm_start", False),
193+
key="warm_start",
194+
help=(
195+
"Seed every replica with the BFS-tree 2-coloring of the "
196+
"graph (a near-optimal cut on bipartite components). "
197+
"Particularly effective on near-bipartite Max-Cut "
198+
"instances (G-set G70 / G77). Has no effect on non-graph "
199+
"problems."
200+
),
201+
)
202+
173203
with st.expander("Display", expanded=False):
174204
update_every = st.slider(
175205
"UI update every (epochs)",
@@ -444,6 +474,24 @@ def on_epoch_end(self, state: CallbackState) -> None:
444474
)
445475
pop_tracker = PopulationTracker(stride=max(1, update_every), record_x=True)
446476

477+
# Build the warm-start seed when requested AND the problem exposes a
478+
# graph attribute. Falls back silently otherwise so the toggle never
479+
# crashes a non-graph run.
480+
initial_state = None
481+
if warm_start:
482+
graph = getattr(problem, "nx_graph", None) or getattr(problem, "graph", None)
483+
if graph is not None:
484+
try:
485+
initial_state = qqa.warmstart.bfs_2color(graph).to(cfg["device"])
486+
st.caption(
487+
f"warm-started {sol_size} replicas from BFS 2-coloring "
488+
f"({initial_state.shape[0]} bits)."
489+
)
490+
except Exception as exc:
491+
# Don't fail the whole run if the heuristic chokes on an
492+
# unusual graph; just log and fall back to the random init.
493+
st.warning(f"warm-start unavailable: {exc}")
494+
447495
try:
448496
result = qqa.anneal(
449497
problem,
@@ -457,6 +505,8 @@ def on_epoch_end(self, state: CallbackState) -> None:
457505
num_epochs=epochs,
458506
device=cfg["device"],
459507
callbacks=[cb, pop_tracker],
508+
initial_state=initial_state,
509+
polish=polish,
460510
verbose=False,
461511
)
462512
except Exception as e:
@@ -468,10 +518,16 @@ def on_epoch_end(self, state: CallbackState) -> None:
468518
raw = (
469519
result.best_obj
470520
if isinstance(result.best_obj, float)
471-
else float(__import__("numpy").asarray(result.best_obj).mean())
521+
else float(np.asarray(result.best_obj).mean())
522+
)
523+
# 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
472528
)
473529
with score_holder.container():
474-
render_score_card(result.score, raw_loss=raw)
530+
render_score_card(result.score, raw_loss=raw, pre_polish_loss=pre_polish)
475531
st.session_state.setdefault("results", []).append(
476532
{
477533
"cfg": dict(cfg),
@@ -484,6 +540,8 @@ def on_epoch_end(self, state: CallbackState) -> None:
484540
"curve_rate": curve_rate,
485541
"div_param": div_param,
486542
"num_epochs": epochs,
543+
"polish": polish,
544+
"warm_start": warm_start,
487545
},
488546
"result": result,
489547
}

app/streamlit_app.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,11 @@
136136
("maxclique", "Max Clique"),
137137
("vertex_cover", "Vertex Cover"),
138138
("graph_bisection", "Graph bisection"),
139+
("min_dominating_set", "Minimum Dominating Set"),
139140
],
140141
"Categorical / assignment": [
141142
("coloring", "Graph coloring"),
143+
("bgp", "Balanced graph partition (K-way)"),
142144
("tsp", "Travelling Salesman (TSP)"),
143145
("qap", "Quadratic Assignment (QAP)"),
144146
("nqueens", "N-Queens"),
@@ -181,11 +183,24 @@
181183
seed = st.number_input("Seed", min_value=0, max_value=10_000, value=0)
182184

183185
# Per-problem auxiliary controls.
184-
if problem_kind in {"mis", "maxcut", "maxclique", "vertex_cover", "graph_bisection"}:
186+
if problem_kind in {
187+
"mis",
188+
"maxcut",
189+
"maxclique",
190+
"vertex_cover",
191+
"graph_bisection",
192+
"min_dominating_set",
193+
}:
185194
extra["graph_d"] = st.slider("Random-regular degree d", 2, 8, 3)
186195
if problem_kind == "coloring":
187196
extra["num_category"] = st.slider("Number of colours K", 2, 6, 3)
188197
extra["graph_d"] = st.slider("Random-regular degree d", 2, 8, 3)
198+
if problem_kind == "bgp":
199+
extra["num_category"] = st.slider("Number of partitions K", 2, 8, 3)
200+
extra["graph_d"] = st.slider("Random-regular degree d", 2, 8, 3)
201+
extra["balance_penalty"] = st.slider(
202+
"Balance penalty", 0.0001, 0.01, 0.0005, 0.0001, format="%.4f"
203+
)
189204
if problem_kind == "ea":
190205
extra["dim"] = st.selectbox("Lattice dim", (2, 3), index=1)
191206
if problem_kind == "perceptron":

tests/test_gui_apptest.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,92 @@ def test_compare_page_shootout_mode_runs_pqqa_vs_sa():
392392
)
393393

394394

395+
def test_solve_page_exposes_polish_and_warmstart_toggles():
396+
"""The Solve page must surface the post-processing & warm-start toggles
397+
introduced in the v0.5 release. Polish defaults to ON; warm-start
398+
defaults to OFF (only useful for graph problems)."""
399+
at = AppTest.from_file(str(PAGE_DIR / "1_Solve.py"), default_timeout=60)
400+
at.session_state["problem_config"] = {
401+
"kind": "maxcut",
402+
"size": 16,
403+
"seed": 0,
404+
"device": "cpu",
405+
"extra": {"graph_d": 3},
406+
}
407+
at.run()
408+
assert not at.exception, at.exception
409+
toggle_labels = [t.label for t in at.sidebar.toggle]
410+
assert any("polish" in lab.lower() for lab in toggle_labels), (
411+
f"Polish toggle missing; got {toggle_labels!r}"
412+
)
413+
assert any(
414+
"warm-start" in lab.lower() or "warm start" in lab.lower() for lab in toggle_labels
415+
), f"Warm-start toggle missing; got {toggle_labels!r}"
416+
polish_toggles = [t for t in at.sidebar.toggle if "polish" in t.label.lower()]
417+
assert polish_toggles[0].value is True, "Polish toggle should default to ON"
418+
warm_toggles = [
419+
t
420+
for t in at.sidebar.toggle
421+
if "warm-start" in t.label.lower() or "warm start" in t.label.lower()
422+
]
423+
assert warm_toggles[0].value is False, "Warm-start toggle should default to OFF"
424+
425+
426+
def test_home_page_lists_min_dominating_set_and_bgp():
427+
"""The new problem catalog entries (MinimumDominatingSet,
428+
BalancedGraphPartition) must be selectable from the Home page so the
429+
UI exercises the same registry as the CLI."""
430+
at = AppTest.from_file(str(APP), default_timeout=60)
431+
at.run()
432+
assert not at.exception, at.exception
433+
selectboxes = at.sidebar.selectbox
434+
family_select = next((s for s in selectboxes if "family" in s.label.lower()), None)
435+
problem_select = next((s for s in selectboxes if s.label == "Problem"), None)
436+
assert family_select is not None and problem_select is not None, (
437+
f"Family/Problem selectboxes missing; got {[s.label for s in selectboxes]!r}"
438+
)
439+
# The dropdown is populated with human-readable labels via
440+
# ``format_func``, so search the rendered strings rather than the raw
441+
# kind keys. Default family is "Graph (binary QUBO)".
442+
graph_options = [str(o).lower() for o in (problem_select.options or [])]
443+
assert any("dominating" in o for o in graph_options), (
444+
f"Minimum Dominating Set missing from Graph problems; got {graph_options!r}"
445+
)
446+
family_select.set_value("Categorical / assignment")
447+
at.run()
448+
assert not at.exception, at.exception
449+
problem_select = next(s for s in at.sidebar.selectbox if s.label == "Problem")
450+
cat_options = [str(o).lower() for o in (problem_select.options or [])]
451+
assert any("balanced" in o or "partition" in o for o in cat_options), (
452+
f"Balanced Graph Partition missing from Categorical problems; got {cat_options!r}"
453+
)
454+
455+
456+
def test_solve_runs_with_min_dominating_set_default():
457+
"""End-to-end smoke for the new MinimumDominatingSet problem under
458+
the same UI flow used by every other graph QUBO."""
459+
at = AppTest.from_file(str(PAGE_DIR / "1_Solve.py"), default_timeout=120)
460+
at.session_state["problem_config"] = {
461+
"kind": "min_dominating_set",
462+
"size": 16,
463+
"seed": 0,
464+
"device": "cpu",
465+
"extra": {"graph_d": 3},
466+
}
467+
at.run()
468+
assert not at.exception, at.exception
469+
_set_slider(at, "sol_size", 8)
470+
_set_slider(at, "epochs", 100)
471+
_set_slider(at, "UI update every", 10)
472+
at.run()
473+
assert not at.exception, at.exception
474+
runs = [b for b in at.button if "Run" in b.label]
475+
assert runs, "Run QQA button missing"
476+
runs[0].click()
477+
at.run()
478+
assert not at.exception, at.exception
479+
480+
395481
def test_solution_viz_smoke_across_problem_kinds(tmp_path):
396482
"""Every registered renderer in ``_solution_viz`` must accept a real
397483
``(problem, result, cfg)`` triple without raising.

0 commit comments

Comments
 (0)