From 19204ce91d6a29a569a2f1f1801b4665ae228b1e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 08:02:29 +0000 Subject: [PATCH 1/4] Cut the GitHub release even when the PyPI upload fails `github-release` required `pypi` to succeed, so a failed upload took the tag and the release down with it. That is the wrong coupling. A GitHub Release records that a version was cut from a commit that passed every check. PyPI is a downstream channel, and it can fail for reasons the code has nothing to do with: auth not configured, an outage, a rate limit. Tying the record to the upload means a transient failure leaves no evidence the version existed and forces a full re-run to get it back. `build` succeeding is the gate that matters -- version guards, strict metadata check, and a wheel installed clean and made to solve an instance all live there. The publish stays ordered before the release so a successful run still reflects it, and a failed one stays red and visible rather than being swallowed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Meb35zHKsyBkH2sbWoyKMT --- .github/workflows/release.yml | 15 +++++++++++++-- docs/RELEASING.md | 12 +++++++++--- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d2a9297..b62cb9b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -178,9 +178,20 @@ jobs: # Either a tag was pushed, or someone asked for a real release from the # Actions tab. A TestPyPI dry run deliberately creates neither tag nor # release -- it exists to rehearse, not to leave traces. + # + # Deliberately NOT gated on the publish succeeding. A GitHub Release + # records that a version was cut from a verified commit; PyPI is a + # downstream channel that can fail for reasons the code has nothing to do + # with -- auth not configured, an outage, a rate limit. Losing the tag and + # the release because of that would mean no record of the version and a + # full re-run to get one. `build` succeeding is the gate that matters: + # that is where every check lives. A failed publish stays red and visible, + # and re-running just that job finishes the job. if: >- - startsWith(github.ref, 'refs/tags/') - || github.event.inputs.target == 'pypi' + always() + && needs.build.result == 'success' + && (startsWith(github.ref, 'refs/tags/') + || github.event.inputs.target == 'pypi') runs-on: ubuntu-latest permissions: contents: write # create the tag, the release, and attach the artifacts diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 381f2b7..9e6618b 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -47,9 +47,15 @@ publish unless it also runs in that environment. Repeat on with environment name `testpypi` if you want the dry run below to work. -Until this exists the `pypi` job fails with an OIDC error. Everything before it -still succeeds, so a tag pushed early leaves you with verified artifacts and no -partial publish — re-run the job once the publisher is configured. +Until this exists the `pypi` job fails with an OIDC error. Nothing else is +blocked by it: the build still verifies, the tag is still created and the +GitHub Release is still cut with the artifacts attached. Re-running the failed +job once the publisher is configured completes the release. + +That decoupling is deliberate. A GitHub Release records that a version was cut +from a verified commit; PyPI is a downstream channel that can fail for reasons +the code has nothing to do with. Losing the release because the upload failed +would leave no record of the version and force a full re-run to get one. ### Optional: require a human to approve each publish From a69c72ba4b58334e6c69249f783367ef6bc0b3c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 08:24:03 +0000 Subject: [PATCH 2/4] Add a Research view, and stop hiding the page behind Pyodide jupyddl.learn shipped in 2.3.0 with its results 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. A test pins them together, and pins capabilities.json to the live registries. Rendering the view exposed a defect it did not cause:
was hidden until the worker reported ready, so every page of prose, the requirement matrix and this new view sat behind a full-screen spinner waiting for a 10 MB runtime none of them use. The shell now renders immediately from the committed bundle, the boot notice is a status bar instead of a splash screen, and only the controls that actually run a planner are gated on state.ready. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Meb35zHKsyBkH2sbWoyKMT --- AGENTS.md | 8 +- CHANGELOG.md | 21 +++++ README.md | 10 ++- tests/test_web_bundle.py | 86 +++++++++++++++++++- tools/build_web.py | 71 +++++++++++++++++ web/app.js | 165 +++++++++++++++++++++++++++++++++++---- web/dist/research.json | 163 ++++++++++++++++++++++++++++++++++++++ web/index.html | 117 +++++++++++++++++++++++++-- web/style.css | 31 ++++++-- 9 files changed, 642 insertions(+), 30 deletions(-) create mode 100644 web/dist/research.json diff --git a/AGENTS.md b/AGENTS.md index ccc5de7..6d54787 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,7 +75,13 @@ native build step, and the core has zero runtime dependencies. - `jupyddl/viz/` — everything that imports matplotlib. Nothing in the core may import this package. - `web/` — the Pyodide playground; `tools/build_web.py` bundles the package - sources and demos into `web/dist` (committed). + sources and demos into `web/dist` (committed). It also writes + `capabilities.json` (the registries) and `research.json` (distilled from + `promo/rl-data.json`, so the page and the RL video quote one measured run). + Those two are rendered **before** Pyodide loads — the app shell is never + hidden, and only the run controls are gated on `state.ready` — so a stale + bundle briefly states something untrue rather than merely lagging. + `tests/test_web_bundle.py` pins both. - `tools/make_promo.py` — renders the main promo video from measured runs. - `tools/make_learn_promo.py` — the learned-heuristic/RL video. It re-measures everything including both failure modes, so it cannot drift from `.docs/`; diff --git a/CHANGELOG.md b/CHANGELOG.md index 8364710..019c938 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,27 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- **A Research view in the workbench.** `jupyddl.learn` shipped in 2.3.0 with + its results only in `.docs/` and a video, which meant the published site said + nothing about the biggest addition in the release. The view carries the + measured run: imitation against `hff` and `goalcount`, the per-instance + spread behind the mean, the logistics loss and why the feature space causes + it, and the three claims that turned out to be wrong. Its numbers are built + from `promo/rl-data.json` — the same cache the RL video renders from — so the + page and the video cannot drift apart, and a test pins them together. + +### Fixed +- **Reading the workbench no longer costs a 10 MB download.** `
` was + hidden until Pyodide reported ready, so every page of prose, the requirement + matrix and the new Research view sat behind a full-screen spinner waiting for + a runtime none of them use. The shell now renders immediately from the + committed bundle, the boot notice is a status bar rather than a splash + screen, and only the controls that actually run a planner stay disabled until + the interpreter arrives. + ## [2.3.0] - 2026-07-31 ### Added diff --git a/README.md b/README.md index 3853278..affa27a 100644 --- a/README.md +++ b/README.md @@ -255,7 +255,7 @@ core is stdlib-only there is no wheel to resolve: the package sources are handed straight to the interpreter. Everything is computed in your tab; nothing is uploaded. -Four views: +Five views: - **Solve** — edit the PDDL, pick a planner, a heuristic and a budget, then watch the cost curves and the search wavefront animate while it works. Or ground @@ -265,9 +265,17 @@ Four views: sorts on any column, and the whole run exports to CSV or JSON. - **PDDL support** — the requirement matrix, filterable by support level, read straight out of the library rather than transcribed. +- **Research** — what `jupyddl.learn` measured: the imitation result, what the + reinforcement stage added, the domain where the whole approach loses and the + exact reason, and the three claims that turned out to be wrong. - **Generate** — produce a reproducible instance from a *(kind, size, seed)* and open it in Solve. +The pages that are only text and measurements — **PDDL support** and +**Research** — render immediately from the committed bundle. Only the controls +that actually run a planner wait for the interpreter, so reading the workbench +never costs a 10 MB download. +
The jupyddl workbench: a PDDL editor beside live cost-estimate charts, a radial search wavefront, and the resulting validated plan. diff --git a/tests/test_web_bundle.py b/tests/test_web_bundle.py index f1cbf02..fd79639 100644 --- a/tests/test_web_bundle.py +++ b/tests/test_web_bundle.py @@ -90,10 +90,94 @@ def test_bundle_is_ordered_independently_of_the_filesystem(sources): assert list(sources) == sorted(sources) +def test_capabilities_bundle_agrees_with_the_registries(): + """The page renders the support matrix *before* Python loads. + + It does that from ``capabilities.json``, which means a stale bundle no + longer merely lags — it states something untrue about the library to every + visitor, and keeps stating it for the seconds before the runtime arrives + and overwrites it. + """ + path = os.path.join(DIST, "capabilities.json") + if not os.path.exists(path): + pytest.skip("web bundle not built") + with open(path, encoding="utf-8") as handle: + caps = json.load(handle) + + from jupyddl.generator import describe_generators + from jupyddl.heuristics import HEURISTICS + from jupyddl.requirements import as_rows, summary + from jupyddl.search import describe_planners + + assert caps["requirements"] == as_rows() + assert caps["requirement_summary"] == summary() + assert caps["planners"] == describe_planners() + assert caps["heuristics"] == sorted(HEURISTICS) + assert caps["generators"] == describe_generators() + + +def test_research_bundle_quotes_the_measured_run(): + """The Research view must not invent numbers. + + ``collect_research`` distils ``promo/rl-data.json`` — the cache the RL + video renders from — so page and video quote one measured run and cannot + drift apart. When that file is absent the builder emits ``{}`` and the + view says so; that is the only other acceptable state. + """ + path = os.path.join(DIST, "research.json") + if not os.path.exists(path): + pytest.skip("web bundle not built") + with open(path, encoding="utf-8") as handle: + research = json.load(handle) + + measured = os.path.join(REPO_ROOT, "promo", "rl-data.json") + if not research: + assert not os.path.exists(measured) + return + with open(measured, encoding="utf-8") as handle: + data = json.load(handle) + + assert research["corpus"] == data["corpus"]["count"] + assert research["parameters"] == data["imitation"]["parameters"] + assert research["after"]["learned"]["expanded"] == round( + data["transfer_after"]["learned"]["mean_expanded"], 1 + ) + assert research["after"]["hff"]["expanded"] == round( + data["transfer_after"]["hff"]["mean_expanded"], 1 + ) + # A learned heuristic is not admissible, so the claim that survives is + # coverage, not cost. Pin it: the view leads with it. + assert research["after"]["learned"]["coverage"] == 1.0 + + +def test_static_views_do_not_wait_for_the_runtime(): + """Reading the page must not cost a 10 MB WebAssembly download. + + Most of the workbench is prose, a support matrix and measurements, none of + which need Python. Hiding ``
`` until Pyodide reports ready made all + of it unreachable behind a spinner, which is how this regressed once. + """ + with open(os.path.join(WEB, "index.html"), encoding="utf-8") as handle: + markup = handle.read() + assert '
' in markup, "the app shell must render immediately" + + with open(os.path.join(WEB, "app.js"), encoding="utf-8") as handle: + script = handle.read() + # The controls, and only the controls, are what the runtime gates. + assert "state.ready" in script + assert "dist/capabilities.json" in script + + def test_builder_is_reproducible(tmp_path): """Running the builder again must not change the committed bundle.""" before = {} - for name in ("jupyddl-sources.json", "demos.json", "build.json"): + for name in ( + "jupyddl-sources.json", + "demos.json", + "build.json", + "capabilities.json", + "research.json", + ): path = os.path.join(DIST, name) if not os.path.exists(path): pytest.skip("web bundle not built") diff --git a/tools/build_web.py b/tools/build_web.py index c8ced65..9398279 100644 --- a/tools/build_web.py +++ b/tools/build_web.py @@ -201,6 +201,69 @@ def collect_capabilities() -> dict: } +def collect_research() -> dict: + """Distil the learned-heuristic measurements for the Research view. + + Read from ``promo/rl-data.json`` — the cache the RL promo video renders + from — so the page and the video quote the same measured run and cannot + drift apart. Returns ``{}`` when that file is absent, and the view then + says so rather than showing numbers from nowhere. + """ + path = os.path.join(ROOT, "promo", "rl-data.json") + if not os.path.exists(path): + return {} + with open(path, encoding="utf-8") as handle: + data = json.load(handle) + + def rows(summary): + return { + name: { + "expanded": round(entry["mean_expanded"], 1), + "seconds": round(entry["mean_seconds"], 4), + "cost": round(entry["mean_cost"], 1), + "coverage": round(entry["coverage"], 2), + } + for name, entry in summary.items() + } + + spread = data.get("spread", {}) + per_instance = [ + { + "instance": name, + "imitation": spread.get("imitation", {}).get(name), + "tuned": spread.get("hi", {}).get("per_instance", {}).get(name), + "solved": spread.get("imitation_solved", {}).get(name, True), + } + for name in spread.get("instances", []) + ] + + return { + "train_sizes": data.get("space", {}).get("train_sizes"), + "eval_sizes": data.get("space", {}).get("eval_sizes"), + "instances": data.get("space", {}).get("train_instances"), + "features": data.get("space", {}).get("features"), + "predicates": data.get("space", {}).get("predicates", []), + "corpus": data.get("corpus", {}).get("count"), + "parameters": data.get("imitation", {}).get("parameters"), + "mae": data.get("imitation", {}).get("mae"), + "top1": data.get("imitation", {}).get("top1"), + "train_seconds": data.get("imitation", {}).get("seconds"), + "cem_seconds": data.get("cem", {}).get("seconds"), + "before": rows(data.get("transfer_before", {})), + "after": rows(data.get("transfer_after", {})), + "per_instance": per_instance, + "flat": data.get("flat", {}), + "sigma": { + "lo": spread.get("lo", {}).get("sigma"), + "hi": spread.get("hi", {}).get("sigma"), + "lo_mean": round(spread.get("lo", {}).get("mean", 0), 1), + "hi_mean": round(spread.get("hi", {}).get("mean", 0), 1), + }, + "logistics": data.get("logistics", {}), + "budget": spread.get("budget"), + } + + def version() -> str: namespace: dict = {} init = os.path.join(PACKAGE, "__init__.py") @@ -224,6 +287,9 @@ def main() -> int: capabilities = collect_capabilities() with open(os.path.join(OUT, "capabilities.json"), "w", encoding="utf-8") as fh: json.dump(capabilities, fh, indent=1, sort_keys=True) + research = collect_research() + with open(os.path.join(OUT, "research.json"), "w", encoding="utf-8") as fh: + json.dump(research, fh, indent=1, sort_keys=True) with open(os.path.join(OUT, "build.json"), "w", encoding="utf-8") as fh: json.dump({"version": version(), "modules": len(sources)}, fh) @@ -238,6 +304,11 @@ def main() -> int: f"{len(capabilities['planners'])} planners, " f"{len(capabilities['generators'])} generators -> web/dist/capabilities.json" ) + print( + " learned-heuristic measurements -> web/dist/research.json" + if research + else " ! no promo/rl-data.json; the Research view will say so" + ) return 0 diff --git a/web/app.js b/web/app.js index 2bf9597..4fba05a 100644 --- a/web/app.js +++ b/web/app.js @@ -22,6 +22,7 @@ const state = { demos: [], capabilities: null, worker: null, + ready: false, running: false, started: 0, points: [], @@ -85,13 +86,21 @@ function bootMessage(text) { async function boot() { renderLegend(); - const [sources, demos, build] = await Promise.all([ + setRunning(false); // nothing is runnable until the worker reports ready + const [sources, demos, build, research, capabilities] = await Promise.all([ fetch("dist/jupyddl-sources.json").then((r) => r.json()), fetch("dist/demos.json").then((r) => r.json()), fetch("dist/build.json").then((r) => r.json()).catch(() => ({})), + fetch("dist/research.json").then((r) => r.json()).catch(() => ({})), + fetch("dist/capabilities.json").then((r) => r.json()).catch(() => null), ]); state.demos = demos; fillDemos(); + renderResearch(research); + // The bundle carries the same registries the runtime will report, so the + // menus and the support matrix can be filled in now; `onReady` overwrites + // them with what the interpreter actually loaded. + if (capabilities) applyCapabilities(capabilities); // Prefer a vendored runtime (offline / self-hosted); fall back to the CDN. let base = CDN_PYODIDE; @@ -105,7 +114,7 @@ async function boot() { state.worker.onmessage = onWorkerMessage; state.worker.onerror = (event) => { const message = `Worker failed: ${event.message || "unknown error"}`; - if ($("app").hidden) { + if (!state.ready) { bootMessage(message); $("boot").querySelector(".spinner").style.display = "none"; } else { @@ -136,7 +145,7 @@ function onWorkerMessage(event) { } else if (type === "generated") { showGenerated(payload); } else if (type === "error") { - if ($("app").hidden) { + if (!state.ready) { bootMessage(payload.message); $("boot").querySelector(".spinner").style.display = "none"; } else { @@ -146,12 +155,15 @@ function onWorkerMessage(event) { } } -function onReady(payload) { - state.capabilities = payload; - const planners = payload.planners.map((p) => p.name); +// Everything the UI derives from the registries. Called twice: once from the +// committed bundle so the page is usable while Python downloads, and again +// from the live interpreter, which is the authority. +function applyCapabilities(caps) { + state.capabilities = caps; + const planners = caps.planners.map((p) => p.name); fillSelect($("planner"), planners, "astar"); - fillSelect($("heuristic"), payload.heuristics.concat(["none"]), "lmcut"); - fillSelect($("gen-kind"), payload.generators.map((g) => g.name), "gripper"); + fillSelect($("heuristic"), caps.heuristics.concat(["none"]), "lmcut"); + fillSelect($("gen-kind"), caps.generators.map((g) => g.name), "gripper"); updateGeneratorBlurb(); buildChecklist("pick-instances", state.demos.map((d) => ({ @@ -161,15 +173,19 @@ function onReady(payload) { buildChecklist("pick-planners", planners.map((name) => ({ value: name, label: name, checked: ["astar", "gbfs", "bfs"].includes(name), }))); - buildChecklist("pick-heuristics", payload.heuristics.map((name) => ({ + buildChecklist("pick-heuristics", caps.heuristics.map((name) => ({ value: name, label: name, checked: ["lmcut", "hff"].includes(name), }))); renderRequirements(); +} +function onReady(payload) { + applyCapabilities(payload); $("build-info").textContent = `jupyddl ${payload.version} · CPython ${payload.python} · WebAssembly`; $("boot").hidden = true; - $("app").hidden = false; + state.ready = true; + setRunning(false); requestAnimationFrame(repaint); } @@ -231,11 +247,16 @@ function clearError() { function setRunning(running) { state.running = running; - $("run").disabled = running; - $("run-experiment").disabled = running; - $("generate").disabled = running; - $("inspect").disabled = running; - $("run").textContent = running ? "Searching…" : "Run search"; + // Before the runtime is ready there is nothing to run, so the same disabled + // state covers both. The page itself stays readable throughout. + const blocked = running || !state.ready; + $("run").disabled = blocked; + $("run-experiment").disabled = blocked; + $("generate").disabled = blocked; + $("inspect").disabled = blocked; + $("run").textContent = running + ? "Searching…" + : state.ready ? "Run search" : "Loading Python…"; } function limits(nodesId, secondsId) { @@ -609,6 +630,120 @@ function openGeneratedInSolve() { switchView("solve"); } + +/* --------------------------------------------------------------- research */ +// Everything here is read from dist/research.json, which the build distils +// from the same measured run the promo video renders from. Nothing on this +// page is a number somebody typed in, and if the measurements are missing the +// view says so rather than showing stale ones. +function renderResearch(data) { + const missing = !data || !data.after || !data.after.learned; + if (missing) { + $("res-summary").textContent = "measurements unavailable"; + $("res-caption").textContent = + "This build carries no research.json — run tools/build_web.py with " + + "promo/rl-data.json present to populate it."; + return; + } + + const learned = data.after.learned; + const hff = data.after.hff; + const trained = (data.train_sizes || []).join("-"); + const judged = (data.eval_sizes || []).join("-"); + + $("res-summary").textContent = + `trained on ${trained} blocks · judged on ${judged}`; + + const ratio = hff ? (hff.expanded / learned.expanded).toFixed(1) : null; + const speed = hff ? (hff.seconds / learned.seconds).toFixed(0) : null; + const stats = [ + [`${learned.expanded.toFixed(0)}`, "nodes expanded"], + ratio ? [`${ratio}x`, "fewer than h_ff"] : null, + speed ? [`${speed}x`, "faster than h_ff"] : null, + [`${(data.top1 * 100).toFixed(0)}%`, "decisions ranked right"], + ].filter(Boolean); + $("res-stats").innerHTML = stats + .map( + ([value, label]) => + `
${escapeHtml(value)}
` + + `
${escapeHtml(label)}
`, + ) + .join(""); + + $("res-caption").textContent = + `${data.corpus} labelled states from ${data.instances} instances of ` + + `${trained} blocks, ${data.parameters} parameters over ${data.features} ` + + `features, trained in ${data.train_seconds.toFixed(1)}s and tuned in ` + + `${Math.round(data.cem_seconds)}s. Evaluated on ${judged} blocks from a ` + + `seed family no stage of training saw.`; + + const order = ["learned", "hff", "goalcount", "blind"]; + $("res-table").querySelector("tbody").innerHTML = order + .filter((name) => data.after[name]) + .map((name) => { + const row = data.after[name]; + const solved = row.coverage > 0; + const strong = name === "learned"; + const cell = (value) => + strong ? `${escapeHtml(value)}` : escapeHtml(value); + return ( + `${escapeHtml(name)}` + + `${cell(row.coverage.toFixed(2))}` + + `${solved ? cell(row.expanded.toFixed(0)) : "—"}` + + `${solved ? cell(row.seconds.toFixed(3)) : "—"}` + + `${solved ? cell(row.cost.toFixed(1)) : "—"}` + ); + }) + .join(""); + + const budget = data.budget; + $("res-spread").querySelector("tbody").innerHTML = (data.per_instance || []) + .map((row) => { + const unsolved = row.solved === false; + const before = unsolved + ? `${escapeHtml(String(budget))} (unsolved)` + : escapeHtml(String(row.imitation)); + return ( + `${escapeHtml(row.instance)}` + + `${before}${escapeHtml(String(row.tuned))}` + ); + }) + .join(""); + + const log = data.logistics || {}; + $("res-logistics").textContent = + `On logistics it loses to h_ff by roughly ` + + `${Math.round((log.learned || 0) / Math.max(1, log.hff || 1))}x, and the ` + + `reason is exact rather than mysterious: that domain has ` + + `${(log.predicates || []).length} predicates ` + + `(${(log.predicates || []).join(", ")}), so the feature vector is ` + + `${log.features} numbers and cannot say which package is where, only how ` + + `many are somewhere. Top-1 accuracy ${(log.top1 * 100).toFixed(0)}% ` + + `against ${(data.top1 * 100).toFixed(0)}% on blocksworld. Counting is ` + + `blind to topology — which is the case for relational features, stated ` + + `as a measurement.`; + + const flat = data.flat || {}; + const sigma = data.sigma || {}; + $("res-corrections").innerHTML = [ + `
  • The objective is flat where search is already good. ` + + `Tuning on the training ladder moved the score ` + + `${(flat.easy_before || 0).toFixed(1)} → ${(flat.easy_after || 0).toFixed(1)} ` + + `— noise. A rung higher it moved ${Math.round(flat.hard_before)} → ` + + `${Math.round(flat.hard_after)}. Optimise where the search is still bad.
  • `, + `
  • We credited an improvement to the wrong change. A ` + + `validation split and the perturbation scale changed in the same edit, ` + + `and the gain was attributed to the split. Varying one at a time: ` + + `sigma ${sigma.lo} gives ${Math.round(sigma.lo_mean)} expansions, sigma ` + + `${sigma.hi} gives ${Math.round(sigma.hi_mean)} — the scale was doing ` + + `all of it. The split is a guardrail worth keeping, not the knob.
  • `, + `
  • The mean was doing the lying. Nine of ten held-out ` + + `instances improve either way; the whole headline gap is one hard ` + + `instance. The durable claim is the coverage one — imitation could not ` + + `solve it at all.
  • `, + ].join(""); +} + /* ------------------------------------------------------------------ views */ function switchView(name) { for (const section of document.querySelectorAll(".view")) { diff --git a/web/dist/research.json b/web/dist/research.json new file mode 100644 index 0000000..38d671d --- /dev/null +++ b/web/dist/research.json @@ -0,0 +1,163 @@ +{ + "after": { + "blind": { + "cost": 0.0, + "coverage": 0.0, + "expanded": 0.0, + "seconds": 0.0 + }, + "goalcount": { + "cost": 51.0, + "coverage": 1.0, + "expanded": 2483.4, + "seconds": 0.1542 + }, + "hff": { + "cost": 51.8, + "coverage": 1.0, + "expanded": 518.3, + "seconds": 0.5337 + }, + "learned": { + "cost": 48.2, + "coverage": 1.0, + "expanded": 137.1, + "seconds": 0.0365 + } + }, + "before": { + "blind": { + "cost": 0.0, + "coverage": 0.0, + "expanded": 0.0, + "seconds": 0.0 + }, + "goalcount": { + "cost": 51.0, + "coverage": 1.0, + "expanded": 2483.4, + "seconds": 0.1612 + }, + "hff": { + "cost": 51.8, + "coverage": 1.0, + "expanded": 518.3, + "seconds": 0.5578 + }, + "learned": { + "cost": 49.1, + "coverage": 0.9, + "expanded": 365.6, + "seconds": 0.1438 + } + }, + "budget": 30000, + "cem_seconds": 38.99109729299994, + "corpus": 118, + "eval_sizes": [ + 9, + 13 + ], + "features": 14, + "flat": { + "easy_after": 12.25, + "easy_before": 12.833333333333334, + "hard_after": 100.75, + "hard_before": 152.125 + }, + "instances": 12, + "logistics": { + "blocks_top1": 0.9230769230769231, + "features": 8, + "hff": 34.666666666666664, + "learned": 203.83333333333334, + "predicates": [ + "at", + "in" + ], + "top1": 0.6560509554140127 + }, + "mae": 0.9205159587205748, + "parameters": 1025, + "per_instance": [ + { + "imitation": 77, + "instance": "blocksworld-09-7777", + "solved": true, + "tuned": 70 + }, + { + "imitation": 126, + "instance": "blocksworld-09-7778", + "solved": true, + "tuned": 68 + }, + { + "imitation": 70, + "instance": "blocksworld-10-7777", + "solved": true, + "tuned": 58 + }, + { + "imitation": 287, + "instance": "blocksworld-10-7778", + "solved": true, + "tuned": 217 + }, + { + "imitation": 113, + "instance": "blocksworld-11-7777", + "solved": true, + "tuned": 81 + }, + { + "imitation": 150, + "instance": "blocksworld-11-7778", + "solved": true, + "tuned": 111 + }, + { + "imitation": 349, + "instance": "blocksworld-12-7777", + "solved": true, + "tuned": 227 + }, + { + "imitation": 114, + "instance": "blocksworld-12-7778", + "solved": true, + "tuned": 70 + }, + { + "imitation": 30000, + "instance": "blocksworld-13-7777", + "solved": false, + "tuned": 214 + }, + { + "imitation": 2004, + "instance": "blocksworld-13-7778", + "solved": true, + "tuned": 255 + } + ], + "predicates": [ + "clear", + "handempty", + "holding", + "on", + "ontable" + ], + "sigma": { + "hi": 0.15, + "hi_mean": 137.1, + "lo": 0.05, + "lo_mean": 1730.3 + }, + "top1": 0.9230769230769231, + "train_seconds": 0.06553611000003912, + "train_sizes": [ + 3, + 6 + ] +} \ No newline at end of file diff --git a/web/index.html b/web/index.html index 41e68d1..d7d1dcb 100644 --- a/web/index.html +++ b/web/index.html @@ -21,6 +21,7 @@

    jupyddl workbench

    +
    @@ -31,15 +32,17 @@

    jupyddl workbench

    -
    -
    - -

    Starting the Python runtime…

    -

    The planner runs entirely in your browser. Nothing is uploaded.

    -
    + +
    + +

    Starting the Python runtime…

    +

    It runs entirely in your browser. Nothing is uploaded.

    -
    +
    @@ -253,6 +256,106 @@

    PDDL requirement support

    + + +
    +
    +
    +

    Learned heuristics

    + +
    +

    + A planner's heuristic is a learned function waiting to happen. Every + solved instance is a labelled trajectory: the cost of a plan's suffix + from any state on it is that state's cost-to-go. Fitting a network to + those labels is imitation. But imitation optimises a + proxy — what we actually want is the heuristic that makes search expand + the fewest nodes, and that is not a differentiable function of the + weights. Optimising it directly, with the planner as a black box, is + where this becomes reinforcement learning. +

    + +
    + +

    Trained small, judged large

    +

    +
    + + + + + + + + + + + + +
    Greedy best-first search on held-out instances
    HeuristicCoverageNodes expandedSecondsPlan cost
    +
    + +

    Read that mean with care

    +

    + The held-out set has a heavy tail. Most instances sit in a narrow band + and one moves the average on its own — the instance imitation could not + solve at all. Reporting only the mean would be close to reporting that + one instance. +

    +
    + + + + + + + + + + +
    Per instance, imitation versus tuned
    InstanceImitationAfter tuning
    +
    + +

    Where it loses, and why

    +

    + +

    What we got wrong

    +
      + +

      Read further

      +

      + The full write-up — prior work, the MDP the RL stage corresponds to, why + the obvious policy gradient is hard here, and a roadmap — lives in the + repository. +

      + + +

      Train one yourself

      +
      pip install "jupyddl[learn]"
      +
      +jupyddl learn blocksworld --sizes 3-6 --seeds-per-size 3 \
      +    --cem 10 --cem-sizes 9-12 --evaluate 9-13 -o bw.heur.json
      +
      +jupyddl solve domain.pddl problem.pddl -s gbfs -H learned:bw.heur.json
      +

      + Training runs on the standard library alone; the learn + extra adds NumPy purely for speed. A learned heuristic is + not admissible — nothing in the objective bounds it + from above — so pair it with gbfs, or wastar + for a bounded-suboptimality knob, never with an optimality claim. +

      +
      +
      +
      diff --git a/web/style.css b/web/style.css index 87e2750..78e51a9 100644 --- a/web/style.css +++ b/web/style.css @@ -127,12 +127,16 @@ button:focus-visible, select:focus-visible, textarea:focus-visible { .secondary { font-weight: 600; } /* ------------------------------------------------------------------ boot */ -.boot { display: grid; place-items: center; min-height: 70vh; padding: 24px; } -.boot-inner { text-align: center; max-width: 420px; } -.boot-note { color: var(--text-muted); font-size: 13px; } +.bootbar { + display: flex; align-items: center; gap: 10px; flex-wrap: wrap; + padding: 9px 20px; border-bottom: 1px solid var(--border); + background: var(--surface-1); font-size: 13px; +} +.bootbar p { margin: 0; } +.boot-note { color: var(--text-muted); } .spinner { - width: 34px; height: 34px; margin: 0 auto 18px; - border: 3px solid var(--grid); border-top-color: var(--series-1); + width: 15px; height: 15px; flex: none; + border: 2px solid var(--grid); border-top-color: var(--series-1); border-radius: 50%; animation: spin 900ms linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } @@ -419,3 +423,20 @@ main { padding: 18px 20px 40px; max-width: 1500px; margin: 0 auto; } #req-table th { text-align: left; } /* Cut the scrolling checklists on a whole row rather than through one. */ .checklist { max-height: 176px; padding-right: 2px; } + +/* ---- Research view ------------------------------------------------- */ +.subhead { + font-size: 15px; font-weight: 650; letter-spacing: -0.01em; + margin: 22px 0 6px; color: var(--text-primary); +} +.subhead:first-of-type { margin-top: 14px; } +.bullets { padding-left: 18px; } +.bullets li { margin-bottom: 8px; } +.codeblock { + background: var(--surface-1); border: 1px solid var(--border); + border-radius: 8px; padding: 12px 14px; overflow-x: auto; + font-size: 12.5px; line-height: 1.6; margin: 0 0 10px; +} +.codeblock code { color: var(--text-secondary); white-space: pre; } +/* The links in this view reuse .filter, which is a