Skip to content

Commit bb998f8

Browse files
author
Yuma Ichikawa
committed
refactor: consolidate polish helper, collapse duplicated app/bench/relaxation code, share test fixtures, bump 0.6.0
- qqa.polish.apply_polish_if_improves: single entry point for the greedy 1-flip QUBO polish so qqa.anneal / simulated_annealing / population_annealing and both PI-GNN trainers route through one helper instead of five near-identical guarded blocks. - SpinRelaxation.perturb_ now inherits from BinaryRelaxation; both relaxations share the same [0,1] latent cube and noise schedule. - qqa.bench: _load_bench_discs and _load_plot_benchmarks unified onto _load_scripts_module(name). - sa._qubo_glauber_sweep deprecated alias dropped (only in-tree diagnostic caller updated). - app/_common.retheme_plotly replaces the _retheme clone previously duplicated in 2_Visualize.py and 3_Compare.py. - app/_common.as_numpy replaces the _as_np / _as_numpy clone shared with _solution_viz.py. - tests/conftest.py introduced with APP, PAGE_DIR, make_problem_config and set_slider helpers; test_gui_apptest.py drops twelve inline problem_config dicts (-60 lines) and its local _set_slider. - pyproject adds pythonpath = ["tests"] so the conftest helpers are importable from sibling test modules. - CHANGELOG catch-up for 0.5.1 / 0.5.2 / 0.5.3 / 0.6.0. - pyproject version 0.5.3 -> 0.6.0; uv.lock refreshed. Net diff: 15 files changed, +242 / -207 lines. Gates: ruff check + ruff format + pytest (371 passed) + mkdocs --strict all green. Made-with: Cursor
1 parent 3351282 commit bb998f8

16 files changed

Lines changed: 311 additions & 207 deletions

CHANGELOG.md

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,108 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## [Unreleased]
88

9+
## [0.6.0] - 2026-04-20
10+
11+
### Added
12+
13+
- **`qqa.polish.apply_polish_if_improves`**: single entry point for the
14+
greedy 1-flip QUBO polish post-processing. `qqa.anneal`,
15+
`qqa.simulated_annealing`, `qqa.population_annealing` and both
16+
PI-GNN trainers now route through this helper so every backend has
17+
the same "monotone free improvement" contract without five copies of
18+
the same `if polish and Q_mat is not None: …` block.
19+
- **Shared test fixtures** at `tests/conftest.py`: `APP`, `PAGE_DIR`
20+
path constants, a `make_problem_config(kind, size, **extra)` factory
21+
and a `set_slider` helper. Test modules now import these directly,
22+
eliminating twelve copies of the same ``problem_config`` literal in
23+
`test_gui_apptest.py`.
24+
- **`app/_common.retheme_plotly(fig)`**: replaces the ``_retheme`` clone
25+
previously defined once per Streamlit page. Import it alongside
26+
`plotly_layout` so every chart stays in step with the active theme.
27+
- **`app/_common.as_numpy(x)`** (public alias of the former `_as_np`):
28+
imported by `_solution_viz.py` so the two modules share a single
29+
tensor-to-numpy conversion path.
30+
31+
### Changed
32+
33+
- **Benchmark suite refreshed**: the project version now tracks the
34+
"qqa4co-bench" HF dataset (coloring / mis-rrg / ea3d /
35+
balanced-partition / MaxCut G-set families), wired through the
36+
`qqa.bench` public API and `qqa bench run|plot|list|setup` CLI.
37+
- `SpinRelaxation.perturb_` now inherits from `BinaryRelaxation`
38+
both relaxations share the same latent cube `[0, 1]` and therefore
39+
the same noise + ``clamp_`` schedule. Removes a silent copy-paste
40+
drift risk.
41+
- `qqa.bench` collapsed `_load_bench_discs` and `_load_plot_benchmarks`
42+
onto a shared `_load_scripts_module(name)` helper so the two
43+
``sys.path`` / ``importlib`` call sites no longer drift.
44+
- ``tests/`` directory is now on the pytest ``pythonpath`` so test
45+
modules can ``from conftest import …`` the shared helpers.
46+
47+
### Removed
48+
49+
- **`qqa.sa._qubo_glauber_sweep` deprecated alias** dropped — it
50+
forwarded to `_qubo_seq_glauber_sweep` and was only referenced by an
51+
in-tree diagnostic script (updated). The buggy parallel-update
52+
semantics it warned about have been gone since 0.4.0.
53+
54+
## [0.5.3] - 2026-04-20
55+
56+
### Added
57+
58+
- **Backend-aware Visualize layout**: the Streamlit Visualize page now
59+
shows PQQA-only tabs for PQQA runs (family tree, PCA embedding,
60+
diversity, parallel coordinates) and PA-only tabs for PA runs
61+
(ESS, free-energy trajectory, equilibration diagnostic,
62+
Thermodynamics, Lineage vs energy, Ancestry Sankey). Empty
63+
"No snapshots recorded" placeholders are gone.
64+
- **Up-front PA capability probe** in the Solve page: problems that
65+
PA cannot sample (categorical / structured binary, e.g. TSP, QAP,
66+
Coloring, NQueens) now trigger a clear warning banner and disable
67+
the Run button, instead of surfacing a cryptic ``einsum`` /
68+
``NotImplementedError`` mid-run.
69+
- **Three PA-specific visualisation tabs**: Thermodynamics (Q vs β,
70+
internal energy, specific heat), Lineage vs energy, Ancestry
71+
Sankey.
72+
73+
### Changed
74+
75+
- `qqa.simulated_annealing` / `qqa.population_annealing` now accept
76+
`polish=True/False` and expose a `polished_sol` field, matching the
77+
contract `qqa.anneal` has always had. The 1-flip polish is default-on
78+
across all backends so the "best_obj" score card reflects the same
79+
post-processing everywhere.
80+
- `_validate_chain_problem` (used by both SA and PA) now rejects
81+
structured `BinaryRelaxation` (non-flat `shape_fn`, e.g. TSP)
82+
with an actionable error steering users to `qqa.anneal`.
83+
84+
## [0.5.2] - 2026-04-20
85+
86+
### Added
87+
88+
- **`qqa.bench` public Python API** (`run`, `plot`, `list_suites`,
89+
`resolve_suite`) mirroring the `qqa bench` CLI so notebooks can
90+
dispatch a benchmark without subprocess boilerplate.
91+
- **Polished benchmark report figure** (`scripts/plot_benchmarks.py`)
92+
and the corresponding `qqa bench plot` CLI flow.
93+
94+
### Changed
95+
96+
- HF Hub dataset renamed to `qqa4co-bench` (was `discs-benchmarks`);
97+
`scripts/setup_discs_data.sh` and all docs follow suit.
98+
99+
## [0.5.1] - 2026-04-19
100+
101+
### Added
102+
103+
- **`qqa.population_annealing`**: Population Annealing backend with
104+
parallel chain sampling, importance resampling between inverse
105+
temperatures, full free-energy / log-Z estimates and an optional
106+
genealogy / ancestry record. `PAResult` dataclass and
107+
`qqa solve --backend pa` CLI expose the new path.
108+
- **MaxCut G-set benchmark family** via
109+
`scripts/fetch_gset_data.py` + `scripts/maxcut_gset_g70.py`.
110+
9111
## [0.5.0] - 2026-04-19
10112

11113
### Added

app/_common.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,21 @@ def plotly_layout(theme: str | None = None, **overrides) -> dict:
294294
return base
295295

296296

297+
def retheme_plotly(fig):
298+
"""Reskin a Plotly figure so its palette matches the active theme.
299+
300+
Every page mirrors this pattern — wrap any ``viz`` figure in
301+
``retheme_plotly(...)`` before ``st.plotly_chart``. Errors from older
302+
figure objects that don't expose ``update_layout`` are intentionally
303+
swallowed so a single bad chart never breaks the whole page.
304+
"""
305+
import contextlib # noqa: PLC0415 - keep optional import local
306+
307+
with contextlib.suppress(Exception):
308+
fig.update_layout(**plotly_layout())
309+
return fig
310+
311+
297312
def apply_theme() -> None:
298313
"""Inject the active theme's CSS, professional-academic in light mode."""
299314
theme = get_theme()
@@ -1955,12 +1970,24 @@ def _hopfield_preview(problem: Any) -> None:
19551970
)
19561971

19571972

1958-
def _as_np(x) -> np.ndarray:
1973+
def as_numpy(x) -> np.ndarray:
1974+
"""Return a CPU ``numpy`` view of a torch tensor / array / scalar.
1975+
1976+
Shared across the app (problem previews, solution viz). Lives here
1977+
rather than at each import site so behaviour stays consistent if we
1978+
ever need to e.g. detach non-leaf autograd graphs or handle BF16.
1979+
"""
19591980
if hasattr(x, "detach"):
19601981
return x.detach().cpu().numpy()
19611982
return np.asarray(x)
19621983

19631984

1985+
# Backwards-compatible alias — callers inside ``_common.py`` still use
1986+
# ``_as_np``. Keeping the alias avoids a large patch and preserves the
1987+
# "private helper" reading for module-internal call sites.
1988+
_as_np = as_numpy
1989+
1990+
19641991
_DEDICATED_PREVIEWS: dict[str, callable] = {
19651992
"tsp": _tsp_preview,
19661993
"qap": _qap_preview,

app/_solution_viz.py

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,20 +28,14 @@
2828
import numpy as np
2929
import plotly.graph_objects as go
3030
import streamlit as st
31+
from _common import as_numpy as _as_numpy
3132
from _common import hex_to_rgba, palette, plotly_layout
3233

3334
# ---------------------------------------------------------------------------
3435
# Helpers
3536
# ---------------------------------------------------------------------------
3637

3738

38-
def _as_numpy(x) -> np.ndarray:
39-
"""Return a CPU ``numpy`` view of a torch tensor / array / scalar."""
40-
if hasattr(x, "detach"):
41-
x = x.detach().cpu()
42-
return np.asarray(x)
43-
44-
4539
def _graph_layout(g: nx.Graph, seed: int = 0) -> dict:
4640
"""Cache-free 2-D layout suitable for the solution plots."""
4741
if g.number_of_nodes() <= 40:

app/pages/2_Visualize.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
sidebar_brand,
2323
theme_toggle_in_sidebar,
2424
)
25+
from _common import (
26+
retheme_plotly as _retheme,
27+
)
2528
from _solution_viz import render_solution_view # noqa: E402
2629

2730
from qqa import visualization as viz # noqa: E402
@@ -205,13 +208,6 @@ def _tab_or_noop(key: str):
205208
tab_pa_sankey = _tab_or_noop("pa_sankey")
206209

207210

208-
def _retheme(fig):
209-
"""Reskin a Plotly figure produced by ``viz`` to the current theme."""
210-
with contextlib.suppress(Exception):
211-
fig.update_layout(**plotly_layout())
212-
return fig
213-
214-
215211
with tab_hist:
216212
try:
217213
fig = viz.plot_history(result, backend="plotly", show=False)

app/pages/3_Compare.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77

88
from __future__ import annotations
99

10-
import contextlib
1110
import itertools
1211
import sys
1312
from pathlib import Path
@@ -23,18 +22,15 @@
2322
sidebar_brand,
2423
theme_toggle_in_sidebar,
2524
)
25+
from _common import (
26+
retheme_plotly as _retheme,
27+
)
2628

2729
import qqa # noqa: E402
2830
from qqa import visualization as viz # noqa: E402
2931
from qqa.relaxation import CategoricalRelaxation # noqa: E402
3032

3133

32-
def _retheme(fig):
33-
with contextlib.suppress(Exception):
34-
fig.update_layout(**plotly_layout())
35-
return fig
36-
37-
3834
def _scalar_best(obj) -> float:
3935
"""Return a Python float regardless of whether ``best_obj`` is a list."""
4036
if hasattr(obj, "tolist"):

pyproject.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "qqa"
7-
version = "0.5.3"
7+
version = "0.6.0"
88
description = "Quasi-Quantum Annealing (QQA): a general-purpose GPU solver for combinatorial and spin-glass optimization, with PI-GNN/CPRA and a parallel SA baseline."
99
readme = "README.md"
1010
requires-python = ">=3.10"
@@ -144,6 +144,10 @@ dev = [
144144

145145
[tool.pytest.ini_options]
146146
testpaths = ["tests"]
147+
# Add tests/ to sys.path so test modules can ``from conftest import ...``
148+
# for the helpers exposed in tests/conftest.py (APP, PAGE_DIR,
149+
# make_problem_config, set_slider).
150+
pythonpath = ["tests"]
147151
addopts = "-ra"
148152
filterwarnings = [
149153
"ignore::DeprecationWarning:qqa.legacy",

src/qqa/annealing.py

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
import torch
2121

2222
from qqa.callbacks import Callback, CallbackState, HistoryRecorder
23-
from qqa.polish import greedy_one_flip
23+
from qqa.polish import apply_polish_if_improves
2424
from qqa.problems.base import COProblem
2525
from qqa.relaxation import _default_penalty_from_forward
2626
from qqa.schedule import LinearBGSchedule
@@ -380,17 +380,14 @@ def anneal(
380380
# contract is undefined). When the polish strictly improves the QUBO
381381
# objective, we hot-swap best_sol / best_obj / score so callers reading
382382
# ``result.best_sol`` automatically benefit.
383-
polished_sol: torch.Tensor | None = None
384-
if polish and not is_batch and getattr(problem, "Q_mat", None) is not None:
385-
polished_sol = greedy_one_flip(problem, best_sol)
386-
with torch.no_grad():
387-
pol_obj = float(problem.loss_fn(polished_sol.unsqueeze(0)).item())
388-
if pol_obj < best_obj:
389-
best_sol = polished_sol
390-
best_obj = pol_obj
391-
score = safe_score_summary(problem, best_sol, fallback_obj=float(best_obj))
392-
if verbose:
393-
print(f" POLISH : 1-flip improved best_obj -> {best_obj}")
383+
prev_obj = best_obj
384+
best_sol, best_obj, polished_sol = apply_polish_if_improves(
385+
problem, best_sol, best_obj, polish=polish and not is_batch
386+
)
387+
if polished_sol is not None and best_obj < prev_obj:
388+
score = safe_score_summary(problem, best_sol, fallback_obj=float(best_obj))
389+
if verbose:
390+
print(f" POLISH : 1-flip improved best_obj -> {best_obj}")
394391

395392
return AnnealResult(
396393
best_sol=best_sol,

src/qqa/bench.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,18 +71,25 @@ def _scripts_dir() -> Path:
7171
)
7272

7373

74-
def _load_bench_discs():
74+
def _load_scripts_module(name: str):
75+
"""Import a module from ``scripts/`` (``bench_discs`` / ``plot_benchmarks``).
76+
77+
Both runners live as plain ``.py`` files next to the repo and are *not*
78+
shipped in the wheel, so we splice ``scripts/`` into ``sys.path`` once
79+
per call and let :mod:`importlib` do the rest.
80+
"""
7581
sd = _scripts_dir()
7682
if str(sd) not in sys.path:
7783
sys.path.insert(0, str(sd))
78-
return importlib.import_module("bench_discs")
84+
return importlib.import_module(name)
85+
86+
87+
def _load_bench_discs():
88+
return _load_scripts_module("bench_discs")
7989

8090

8191
def _load_plot_benchmarks():
82-
sd = _scripts_dir()
83-
if str(sd) not in sys.path:
84-
sys.path.insert(0, str(sd))
85-
return importlib.import_module("plot_benchmarks")
92+
return _load_scripts_module("plot_benchmarks")
8693

8794

8895
# --------------------------------------------------------------------------- #

src/qqa/pa.py

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141

4242
import torch
4343

44-
from qqa.polish import greedy_one_flip
44+
from qqa.polish import apply_polish_if_improves
4545
from qqa.sa import (
4646
_build_beta_schedule,
4747
_qubo_seq_glauber_sweep,
@@ -422,18 +422,12 @@ def population_annealing(
422422
best_sol_disc = best_sol.detach()
423423

424424
# Default-on greedy 1-flip polish. Shared with :func:`qqa.anneal` via
425-
# ``qqa.polish.greedy_one_flip``; noop when ``Q_mat`` is absent (spin
426-
# / categorical / batched problems). The polish strictly improves
427-
# ``best_obj`` on QUBO landscapes where the MCMC stopped short of a
428-
# 1-flip local minimum, so PA matches PQQA's post-processing contract.
429-
polished_sol: torch.Tensor | None = None
430-
if polish and getattr(problem, "Q_mat", None) is not None:
431-
polished_sol = greedy_one_flip(problem, best_sol_disc)
432-
with torch.no_grad():
433-
pol_obj = float(problem.loss_fn(polished_sol.unsqueeze(0)).item())
434-
if pol_obj < best_obj:
435-
best_obj = pol_obj
436-
best_sol_disc = polished_sol.detach()
425+
# ``qqa.polish.apply_polish_if_improves``; noop when ``Q_mat`` is absent
426+
# (spin / categorical / batched problems). PA therefore matches PQQA's
427+
# post-processing contract by construction.
428+
best_sol_disc, best_obj, polished_sol = apply_polish_if_improves(
429+
problem, best_sol_disc, best_obj, polish=polish
430+
)
437431

438432
score = safe_score_summary(problem, best_sol_disc, fallback_obj=float(best_obj))
439433

0 commit comments

Comments
 (0)