Skip to content

Commit 588c6cc

Browse files
committed
Fix Solve-page Plotly crash and tighten metric-tile layout
* Replace #RRGGBBAA hex8 colour literals with hex_to_rgba() helper. Plotly's validator rejects 8-digit hex, so the live dynamics and diversity plots crashed on every run with "Invalid value ... for the 'fillcolor' property of scatter". * Split the six metric tiles into two rows of three, use short labels (best / mean / sigma (replicas) / bg / elapsed), tighten label letter-spacing, and pin metric value font-size so nothing gets truncated on the hosted Streamlit viewport. * Add tests/test_gui_apptest.py::test_solve_page_end_to_end_run - a real Run-button click with tiny sliders so any future Plotly validator or Streamlit deprecation regression fails in CI instead of in the user's browser. * Add project URLs (Repository / Documentation / Issues / Changelog / Live Demo) to pyproject.toml for PyPI metadata polish. Local CI parity: uv run ruff check src tests scripts app -> clean uv run ruff format --check src tests scripts app -> clean uv run pytest -q -> 52 passed, 1 skipped
1 parent 67ef389 commit 588c6cc

4 files changed

Lines changed: 89 additions & 13 deletions

File tree

app/_common.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,25 @@ def palette(theme: str | None = None) -> dict:
101101
return _LIGHT_PALETTE if theme == "light" else _DARK_PALETTE
102102

103103

104+
def hex_to_rgba(color: str, alpha: float) -> str:
105+
"""Convert a ``#RRGGBB`` hex string into a Plotly-safe ``rgba(...)`` string.
106+
107+
Plotly's property validator rejects the 8-digit ``#RRGGBBAA`` form, so we
108+
emit the functional ``rgba()`` notation instead (``alpha`` is a float in
109+
``[0, 1]``). Non-hex inputs are returned unchanged so callers can safely
110+
pass already-resolved strings.
111+
"""
112+
if not isinstance(color, str) or not color.startswith("#"):
113+
return color
114+
h = color.lstrip("#")
115+
if len(h) == 8: # already hex8 → drop alpha and use caller's alpha instead
116+
h = h[:6]
117+
if len(h) != 6:
118+
return color
119+
r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
120+
return f"rgba({r},{g},{b},{alpha:.3f})"
121+
122+
104123
def plotly_layout(theme: str | None = None, **overrides) -> dict:
105124
"""Plotly layout kwargs consistent with the active theme.
106125
@@ -197,13 +216,16 @@ def apply_theme() -> None:
197216
div[data-testid="stMetric"] label {{
198217
color: var(--qqa-muted) !important;
199218
font-weight: 500;
200-
font-size: 0.72rem;
219+
font-size: 0.7rem;
201220
text-transform: uppercase;
202-
letter-spacing: 0.08em;
221+
letter-spacing: 0.04em;
222+
white-space: nowrap;
223+
overflow: visible;
203224
}}
204225
div[data-testid="stMetric"] [data-testid="stMetricValue"] {{
205226
font-family: 'Source Serif 4', Georgia, serif;
206227
font-weight: 700;
228+
font-size: 1.35rem;
207229
color: var(--qqa-text) !important;
208230
}}
209231
.stButton > button {{
@@ -355,12 +377,15 @@ def apply_theme() -> None:
355377
}}
356378
div[data-testid="stMetric"] label {{
357379
color: var(--qqa-muted) !important;
358-
font-size: 0.72rem;
359-
letter-spacing: 0.08em;
380+
font-size: 0.7rem;
381+
letter-spacing: 0.04em;
360382
text-transform: uppercase;
383+
white-space: nowrap;
384+
overflow: visible;
361385
}}
362386
div[data-testid="stMetric"] [data-testid="stMetricValue"] {{
363387
font-family: 'Source Serif 4', serif;
388+
font-size: 1.35rem;
364389
color: #f8fafc !important;
365390
}}
366391
.stButton > button {{

app/pages/1_Solve.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
apply_theme,
1616
build_problem,
1717
get_theme,
18+
hex_to_rgba,
1819
palette,
1920
plotly_layout,
2021
render_score_card,
@@ -94,13 +95,14 @@ def on_epoch_end(self, state: CallbackState) -> None:
9495
self.progress_bar.progress(min(1.0, (epoch + 1) / total))
9596
elapsed = time.time() - self._start
9697
with self.metrics_holder.container():
97-
c1, c2, c3, c4, c5, c6 = st.columns(6)
98-
c1.metric("epoch", f"{epoch + 1} / {total}")
99-
c2.metric("best / replica", f"{self.best[-1]:.4f}")
100-
c3.metric("mean / replica", f"{self.mean_loss[-1]:.4f}")
101-
c4.metric("σ across replicas", f"{self.std_loss[-1]:.4f}")
102-
c5.metric("bg", f"{state.bg:.3f}")
103-
c6.metric("elapsed", f"{elapsed:.1f}s")
98+
r1c1, r1c2, r1c3 = st.columns(3)
99+
r1c1.metric("epoch", f"{epoch + 1} / {total}")
100+
r1c2.metric("best", f"{self.best[-1]:.4g}")
101+
r1c3.metric("mean", f"{self.mean_loss[-1]:.4g}")
102+
r2c1, r2c2, r2c3 = st.columns(3)
103+
r2c1.metric("σ (replicas)", f"{self.std_loss[-1]:.4g}")
104+
r2c2.metric("bg", f"{state.bg:.3f}")
105+
r2c3.metric("elapsed", f"{elapsed:.1f}s")
104106

105107
p = palette()
106108
theme = get_theme()
@@ -114,7 +116,7 @@ def on_epoch_end(self, state: CallbackState) -> None:
114116
x=self.epochs + self.epochs[::-1],
115117
y=np.concatenate([mean_arr + std_arr, (mean_arr - std_arr)[::-1]]).tolist(),
116118
fill="toself",
117-
fillcolor=p["palette"][0] + ("22" if theme == "light" else "33"),
119+
fillcolor=hex_to_rgba(p["palette"][0], 0.13 if theme == "light" else 0.20),
118120
line={"color": "rgba(0,0,0,0)"},
119121
hoverinfo="skip",
120122
showlegend=False,
@@ -205,7 +207,7 @@ def on_epoch_end(self, state: CallbackState) -> None:
205207
mode="lines",
206208
fill="tozeroy",
207209
line={"color": p["palette"][2], "width": 2},
208-
fillcolor=p["palette"][2] + ("33" if theme == "light" else "55"),
210+
fillcolor=hex_to_rgba(p["palette"][2], 0.20 if theme == "light" else 0.33),
209211
name="loss σ",
210212
)
211213
)

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,12 @@ qqa = "qqa.cli:main"
7474

7575
[project.urls]
7676
Homepage = "https://github.com/Yuma-Ichikawa/QQA4CO"
77+
Repository = "https://github.com/Yuma-Ichikawa/QQA4CO"
78+
Documentation = "https://yuma-ichikawa.github.io/QQA4CO/"
79+
Issues = "https://github.com/Yuma-Ichikawa/QQA4CO/issues"
80+
Changelog = "https://github.com/Yuma-Ichikawa/QQA4CO/blob/main/CHANGELOG.md"
7781
Paper = "https://openreview.net/forum?id=9EfBeXaXf0"
82+
"Live Demo" = "https://parallelquasiquantum4co.streamlit.app/"
7883

7984
[tool.hatch.build.targets.wheel]
8085
packages = ["src/qqa"]

tests/test_gui_apptest.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,47 @@ def test_visualize_page_handles_missing_run():
9393
assert not at.exception
9494
warnings = [w.body for w in at.warning]
9595
assert any("Solve page" in w for w in warnings)
96+
97+
98+
def _set_slider(at, label_fragment: str, value) -> None:
99+
"""Set the slider whose label contains ``label_fragment`` to ``value``."""
100+
matches = [s for s in at.sidebar.slider if label_fragment in s.label]
101+
assert matches, f"No slider whose label contains {label_fragment!r}"
102+
matches[0].set_value(value)
103+
104+
105+
def test_solve_page_end_to_end_run():
106+
"""Full Solve flow: wire up a tiny problem and click Run.
107+
108+
Exercises the live-callback path (Plotly fillcolor validation, metric
109+
tiles, population heatmap, diversity curve, and the final score card).
110+
A previous regression fed Plotly an 8-hex colour ('#RRGGBBAA') and only
111+
surfaced at runtime; this test pins the contract.
112+
"""
113+
at = AppTest.from_file(str(PAGE_DIR / "1_Solve.py"), default_timeout=90)
114+
at.session_state["problem_config"] = {
115+
"kind": "ising1d",
116+
"size": 8,
117+
"seed": 0,
118+
"device": "cpu",
119+
"extra": {},
120+
}
121+
at.run()
122+
assert not at.exception, at.exception
123+
124+
# Shrink every slider so the test runs in a few seconds.
125+
_set_slider(at, "sol_size", 4)
126+
_set_slider(at, "epochs", 100)
127+
_set_slider(at, "UI update every", 10)
128+
at.run()
129+
assert not at.exception, at.exception
130+
131+
runs = [b for b in at.button if "Run" in b.label]
132+
assert runs, "Run QQA button missing"
133+
runs[0].click()
134+
at.run()
135+
136+
assert not at.exception, at.exception
137+
# Success is observable as either the score card or the raw-loss caption.
138+
texts = " ".join([m.value for m in at.markdown if m.value])
139+
assert "qqa-score" in texts or "energy" in texts.lower() or "raw loss" in texts

0 commit comments

Comments
 (0)