Skip to content

Commit 0ca272e

Browse files
authored
Publish the learned-heuristic results: a Research view in the workbench (#161)
jupyddl.learn shipped in 2.3.0 with its results living only in .docs/ and a video, so the published site said nothing about the largest addition in the release. The workbench now carries a Research view with the measured run: imitation against hff and goalcount, the per-instance spread behind the mean, the logistics loss and the exact reason for it, and the three claims that turned out to be wrong. Its numbers come from promo/rl-data.json, the cache the RL video renders from, so the page and the video quote one measured run and cannot drift apart. Tests pin that, and pin capabilities.json to the live registries. Rendering the view exposed a defect it did not cause: <main> was hidden until the worker reported ready, so every page of prose, the requirement matrix and the new view sat behind a spinner waiting for a 10 MB runtime none of them use. The shell now renders immediately from the committed bundle and only the run controls are gated on state.ready. Also carried: the GitHub Release job no longer requires the PyPI upload to have succeeded, and renovate no longer proposes bumping the workflows' Python pin.
1 parent 0cf36f1 commit 0ca272e

12 files changed

Lines changed: 697 additions & 36 deletions

File tree

.github/workflows/release.yml

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,9 +178,20 @@ jobs:
178178
# Either a tag was pushed, or someone asked for a real release from the
179179
# Actions tab. A TestPyPI dry run deliberately creates neither tag nor
180180
# release -- it exists to rehearse, not to leave traces.
181+
#
182+
# Deliberately NOT gated on the publish succeeding. A GitHub Release
183+
# records that a version was cut from a verified commit; PyPI is a
184+
# downstream channel that can fail for reasons the code has nothing to do
185+
# with -- auth not configured, an outage, a rate limit. Losing the tag and
186+
# the release because of that would mean no record of the version and a
187+
# full re-run to get one. `build` succeeding is the gate that matters:
188+
# that is where every check lives. A failed publish stays red and visible,
189+
# and re-running just that job finishes the job.
181190
if: >-
182-
startsWith(github.ref, 'refs/tags/')
183-
|| github.event.inputs.target == 'pypi'
191+
always()
192+
&& needs.build.result == 'success'
193+
&& (startsWith(github.ref, 'refs/tags/')
194+
|| github.event.inputs.target == 'pypi')
184195
runs-on: ubuntu-latest
185196
permissions:
186197
contents: write # create the tag, the release, and attach the artifacts

AGENTS.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,13 @@ native build step, and the core has zero runtime dependencies.
7575
- `jupyddl/viz/` — everything that imports matplotlib. Nothing in the core may
7676
import this package.
7777
- `web/` — the Pyodide playground; `tools/build_web.py` bundles the package
78-
sources and demos into `web/dist` (committed).
78+
sources and demos into `web/dist` (committed). It also writes
79+
`capabilities.json` (the registries) and `research.json` (distilled from
80+
`promo/rl-data.json`, so the page and the RL video quote one measured run).
81+
Those two are rendered **before** Pyodide loads — the app shell is never
82+
hidden, and only the run controls are gated on `state.ready` — so a stale
83+
bundle briefly states something untrue rather than merely lagging.
84+
`tests/test_web_bundle.py` pins both.
7985
- `tools/make_promo.py` — renders the main promo video from measured runs.
8086
- `tools/make_learn_promo.py` — the learned-heuristic/RL video. It re-measures
8187
everything including both failure modes, so it cannot drift from `.docs/`;

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,27 @@ All notable changes to this project are documented in this file. The format is
44
based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this
55
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [Unreleased]
8+
9+
### Added
10+
- **A Research view in the workbench.** `jupyddl.learn` shipped in 2.3.0 with
11+
its results only in `.docs/` and a video, which meant the published site said
12+
nothing about the biggest addition in the release. The view carries the
13+
measured run: imitation against `hff` and `goalcount`, the per-instance
14+
spread behind the mean, the logistics loss and why the feature space causes
15+
it, and the three claims that turned out to be wrong. Its numbers are built
16+
from `promo/rl-data.json` — the same cache the RL video renders from — so the
17+
page and the video cannot drift apart, and a test pins them together.
18+
19+
### Fixed
20+
- **Reading the workbench no longer costs a 10 MB download.** `<main>` was
21+
hidden until Pyodide reported ready, so every page of prose, the requirement
22+
matrix and the new Research view sat behind a full-screen spinner waiting for
23+
a runtime none of them use. The shell now renders immediately from the
24+
committed bundle, the boot notice is a status bar rather than a splash
25+
screen, and only the controls that actually run a planner stay disabled until
26+
the interpreter arrives.
27+
728
## [2.3.0] - 2026-07-31
829

930
### Added

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ core is stdlib-only there is no wheel to resolve: the package sources are handed
255255
straight to the interpreter. Everything is computed in your tab; nothing is
256256
uploaded.
257257

258-
Four views:
258+
Five views:
259259

260260
- **Solve** — edit the PDDL, pick a planner, a heuristic and a budget, then watch
261261
the cost curves and the search wavefront animate while it works. Or ground
@@ -265,9 +265,17 @@ Four views:
265265
sorts on any column, and the whole run exports to CSV or JSON.
266266
- **PDDL support** — the requirement matrix, filterable by support level, read
267267
straight out of the library rather than transcribed.
268+
- **Research** — what `jupyddl.learn` measured: the imitation result, what the
269+
reinforcement stage added, the domain where the whole approach loses and the
270+
exact reason, and the three claims that turned out to be wrong.
268271
- **Generate** — produce a reproducible instance from a *(kind, size, seed)* and
269272
open it in Solve.
270273

274+
The pages that are only text and measurements — **PDDL support** and
275+
**Research** — render immediately from the committed bundle. Only the controls
276+
that actually run a planner wait for the interpreter, so reading the workbench
277+
never costs a 10 MB download.
278+
271279
<div align="center">
272280

273281
<img src="promo/workbench-dark.png" alt="The jupyddl workbench: a PDDL editor beside live cost-estimate charts, a radial search wavefront, and the resulting validated plan." width="880">

docs/RELEASING.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,15 @@ publish unless it also runs in that environment.
4747
Repeat on <https://test.pypi.org> with environment name `testpypi` if you want
4848
the dry run below to work.
4949

50-
Until this exists the `pypi` job fails with an OIDC error. Everything before it
51-
still succeeds, so a tag pushed early leaves you with verified artifacts and no
52-
partial publish — re-run the job once the publisher is configured.
50+
Until this exists the `pypi` job fails with an OIDC error. Nothing else is
51+
blocked by it: the build still verifies, the tag is still created and the
52+
GitHub Release is still cut with the artifacts attached. Re-running the failed
53+
job once the publisher is configured completes the release.
54+
55+
That decoupling is deliberate. A GitHub Release records that a version was cut
56+
from a verified commit; PyPI is a downstream channel that can fail for reasons
57+
the code has nothing to do with. Losing the release because the upload failed
58+
would leave no record of the version and force a full re-run to get one.
5359

5460
### Optional: require a human to approve each publish
5561

renovate.json

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
{
22
"extends": [
3-
"config:base"
3+
"config:recommended"
4+
],
5+
"packageRules": [
6+
{
7+
"description": [
8+
"The `python-version` pins in build.yml, pages.yml and release.yml are",
9+
"not a dependency to keep current -- they choose which interpreter",
10+
"builds the wheel, bundles web/dist and cuts the release. `tests`",
11+
"already runs the whole supported range (3.9-3.14) on every push, and",
12+
"the wheel is py3-none-any, so building on the newest interpreter buys",
13+
"nothing and ties the release pipeline to whatever is newest on the",
14+
"runner. Moving that pin should be a deliberate edit, not a bot PR."
15+
],
16+
"matchManagers": ["github-actions"],
17+
"matchDepNames": ["python"],
18+
"enabled": false
19+
}
420
]
521
}

tests/test_web_bundle.py

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,10 +90,94 @@ def test_bundle_is_ordered_independently_of_the_filesystem(sources):
9090
assert list(sources) == sorted(sources)
9191

9292

93+
def test_capabilities_bundle_agrees_with_the_registries():
94+
"""The page renders the support matrix *before* Python loads.
95+
96+
It does that from ``capabilities.json``, which means a stale bundle no
97+
longer merely lags — it states something untrue about the library to every
98+
visitor, and keeps stating it for the seconds before the runtime arrives
99+
and overwrites it.
100+
"""
101+
path = os.path.join(DIST, "capabilities.json")
102+
if not os.path.exists(path):
103+
pytest.skip("web bundle not built")
104+
with open(path, encoding="utf-8") as handle:
105+
caps = json.load(handle)
106+
107+
from jupyddl.generator import describe_generators
108+
from jupyddl.heuristics import HEURISTICS
109+
from jupyddl.requirements import as_rows, summary
110+
from jupyddl.search import describe_planners
111+
112+
assert caps["requirements"] == as_rows()
113+
assert caps["requirement_summary"] == summary()
114+
assert caps["planners"] == describe_planners()
115+
assert caps["heuristics"] == sorted(HEURISTICS)
116+
assert caps["generators"] == describe_generators()
117+
118+
119+
def test_research_bundle_quotes_the_measured_run():
120+
"""The Research view must not invent numbers.
121+
122+
``collect_research`` distils ``promo/rl-data.json`` — the cache the RL
123+
video renders from — so page and video quote one measured run and cannot
124+
drift apart. When that file is absent the builder emits ``{}`` and the
125+
view says so; that is the only other acceptable state.
126+
"""
127+
path = os.path.join(DIST, "research.json")
128+
if not os.path.exists(path):
129+
pytest.skip("web bundle not built")
130+
with open(path, encoding="utf-8") as handle:
131+
research = json.load(handle)
132+
133+
measured = os.path.join(REPO_ROOT, "promo", "rl-data.json")
134+
if not research:
135+
assert not os.path.exists(measured)
136+
return
137+
with open(measured, encoding="utf-8") as handle:
138+
data = json.load(handle)
139+
140+
assert research["corpus"] == data["corpus"]["count"]
141+
assert research["parameters"] == data["imitation"]["parameters"]
142+
assert research["after"]["learned"]["expanded"] == round(
143+
data["transfer_after"]["learned"]["mean_expanded"], 1
144+
)
145+
assert research["after"]["hff"]["expanded"] == round(
146+
data["transfer_after"]["hff"]["mean_expanded"], 1
147+
)
148+
# A learned heuristic is not admissible, so the claim that survives is
149+
# coverage, not cost. Pin it: the view leads with it.
150+
assert research["after"]["learned"]["coverage"] == 1.0
151+
152+
153+
def test_static_views_do_not_wait_for_the_runtime():
154+
"""Reading the page must not cost a 10 MB WebAssembly download.
155+
156+
Most of the workbench is prose, a support matrix and measurements, none of
157+
which need Python. Hiding ``<main>`` until Pyodide reports ready made all
158+
of it unreachable behind a spinner, which is how this regressed once.
159+
"""
160+
with open(os.path.join(WEB, "index.html"), encoding="utf-8") as handle:
161+
markup = handle.read()
162+
assert '<main id="app">' in markup, "the app shell must render immediately"
163+
164+
with open(os.path.join(WEB, "app.js"), encoding="utf-8") as handle:
165+
script = handle.read()
166+
# The controls, and only the controls, are what the runtime gates.
167+
assert "state.ready" in script
168+
assert "dist/capabilities.json" in script
169+
170+
93171
def test_builder_is_reproducible(tmp_path):
94172
"""Running the builder again must not change the committed bundle."""
95173
before = {}
96-
for name in ("jupyddl-sources.json", "demos.json", "build.json"):
174+
for name in (
175+
"jupyddl-sources.json",
176+
"demos.json",
177+
"build.json",
178+
"capabilities.json",
179+
"research.json",
180+
):
97181
path = os.path.join(DIST, name)
98182
if not os.path.exists(path):
99183
pytest.skip("web bundle not built")

tools/build_web.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,69 @@ def collect_capabilities() -> dict:
201201
}
202202

203203

204+
def collect_research() -> dict:
205+
"""Distil the learned-heuristic measurements for the Research view.
206+
207+
Read from ``promo/rl-data.json`` — the cache the RL promo video renders
208+
from — so the page and the video quote the same measured run and cannot
209+
drift apart. Returns ``{}`` when that file is absent, and the view then
210+
says so rather than showing numbers from nowhere.
211+
"""
212+
path = os.path.join(ROOT, "promo", "rl-data.json")
213+
if not os.path.exists(path):
214+
return {}
215+
with open(path, encoding="utf-8") as handle:
216+
data = json.load(handle)
217+
218+
def rows(summary):
219+
return {
220+
name: {
221+
"expanded": round(entry["mean_expanded"], 1),
222+
"seconds": round(entry["mean_seconds"], 4),
223+
"cost": round(entry["mean_cost"], 1),
224+
"coverage": round(entry["coverage"], 2),
225+
}
226+
for name, entry in summary.items()
227+
}
228+
229+
spread = data.get("spread", {})
230+
per_instance = [
231+
{
232+
"instance": name,
233+
"imitation": spread.get("imitation", {}).get(name),
234+
"tuned": spread.get("hi", {}).get("per_instance", {}).get(name),
235+
"solved": spread.get("imitation_solved", {}).get(name, True),
236+
}
237+
for name in spread.get("instances", [])
238+
]
239+
240+
return {
241+
"train_sizes": data.get("space", {}).get("train_sizes"),
242+
"eval_sizes": data.get("space", {}).get("eval_sizes"),
243+
"instances": data.get("space", {}).get("train_instances"),
244+
"features": data.get("space", {}).get("features"),
245+
"predicates": data.get("space", {}).get("predicates", []),
246+
"corpus": data.get("corpus", {}).get("count"),
247+
"parameters": data.get("imitation", {}).get("parameters"),
248+
"mae": data.get("imitation", {}).get("mae"),
249+
"top1": data.get("imitation", {}).get("top1"),
250+
"train_seconds": data.get("imitation", {}).get("seconds"),
251+
"cem_seconds": data.get("cem", {}).get("seconds"),
252+
"before": rows(data.get("transfer_before", {})),
253+
"after": rows(data.get("transfer_after", {})),
254+
"per_instance": per_instance,
255+
"flat": data.get("flat", {}),
256+
"sigma": {
257+
"lo": spread.get("lo", {}).get("sigma"),
258+
"hi": spread.get("hi", {}).get("sigma"),
259+
"lo_mean": round(spread.get("lo", {}).get("mean", 0), 1),
260+
"hi_mean": round(spread.get("hi", {}).get("mean", 0), 1),
261+
},
262+
"logistics": data.get("logistics", {}),
263+
"budget": spread.get("budget"),
264+
}
265+
266+
204267
def version() -> str:
205268
namespace: dict = {}
206269
init = os.path.join(PACKAGE, "__init__.py")
@@ -224,6 +287,9 @@ def main() -> int:
224287
capabilities = collect_capabilities()
225288
with open(os.path.join(OUT, "capabilities.json"), "w", encoding="utf-8") as fh:
226289
json.dump(capabilities, fh, indent=1, sort_keys=True)
290+
research = collect_research()
291+
with open(os.path.join(OUT, "research.json"), "w", encoding="utf-8") as fh:
292+
json.dump(research, fh, indent=1, sort_keys=True)
227293
with open(os.path.join(OUT, "build.json"), "w", encoding="utf-8") as fh:
228294
json.dump({"version": version(), "modules": len(sources)}, fh)
229295

@@ -238,6 +304,11 @@ def main() -> int:
238304
f"{len(capabilities['planners'])} planners, "
239305
f"{len(capabilities['generators'])} generators -> web/dist/capabilities.json"
240306
)
307+
print(
308+
" learned-heuristic measurements -> web/dist/research.json"
309+
if research
310+
else " ! no promo/rl-data.json; the Research view will say so"
311+
)
241312
return 0
242313

243314

0 commit comments

Comments
 (0)