Skip to content

Observable search, most of PDDL 3, and a browser research workbench - #151

Open
guilyx wants to merge 3 commits into
mainfrom
claude/library-demo-plots-webpage-8r8uz4
Open

Observable search, most of PDDL 3, and a browser research workbench#151
guilyx wants to merge 3 commits into
mainfrom
claude/library-demo-plots-webpage-8r8uz4

Conversation

@guilyx

@guilyx guilyx commented Jul 28, 2026

Copy link
Copy Markdown
Member

Three commits that take jupyddl from a STRIPS planner you run blind to one you can watch, sweep, and point at most of PDDL 3.

1. Make the search observable

One observer hook on every planner; everything else reads from it.

  • jupyddl.traceSearchObserver with no-op defaults, a TraceRecorder producing a serialisable SearchTrace (JSON round-trip, bounded memory via adaptive thinning), and trace_search().
  • jupyddl.live — a live terminal dashboard with Unicode sparklines and a frontier gauge. Stdlib only, so watching a search still costs no dependency; degrades to one-line output on a non-tty.
  • jupyddl.viz (the viz extra) — search-progress, planner-comparison, benchmark-dashboard, plan-timeline and a radial "wavefront" chart where a well-guided search reads as a narrow spike and a blind one fills the disc. Light and dark both validated for colour-vision-deficiency separation.
  • Tracing is opt-in and provably transparent: the suite asserts an observed search returns the identical plan and statistics to an unobserved one.

2. Grow the PDDL fragment

jupyddl.requirements is a single registry saying what happens to every requirement flag — native, compiled, partial or rejected — with the reason. The parser validates against it, jupyddl requirements prints it, the README table is generated from it, and the web UI renders it, so they cannot drift apart.

20 of 21 flags are now supported, up from a STRIPS-plus-a-little-ADL subset:

Area What landed
ADL or, imply, exists, nested not — parser emits NNF, grounder expands quantifiers and distributes to DNF, one operator per disjunct
Derived predicates Recursive axioms closed to a least fixpoint after every state change
Numeric fluents Comparisons plus assign/increase/decrease/scale-*
Durative actions Compiled to a sequential schedule with Task.makespan
Trajectory constraints always, at-end, sometime, sometime-before, sometime-after, at-most-once
Preferences Soft goals compiled to a priced satisfy-or-pay choice behind a closing phase
Timed initial literals A clock fluent, firing/wait actions, and time-ordered firing
Object fluents Compiled to a predicate plus a uniqueness rule
Duration inequalities Shortest feasible duration chosen

PDDL 3 is handled by jupyddl/compile.py as source-to-source rewriting before grounding, so the search engine never learns these constructs exist.

A rejected flag fails at parse time with an explanation. Silently ignoring :preferences would produce plans that are wrong rather than absent — worse than refusing.

Also: 5 more planners (hill climbing, beam, Iterated Width, branch-and-bound, anytime weighted A*) for 14 total, search budgets on every planner, and 7 seeded instance generators.

3. The browser workbench

Runs this library, unmodified, under Pyodide — not a JS reimplementation. Four views: Solve (live charts + wavefront), Experiment (instance × configuration sweep sharing one grounding per instance, streaming into a sortable table with CSV/JSON export), PDDL support, and Generate. Verified end-to-end in Chromium.

Bugs found and fixed along the way

  • validate_plan was wrong for the new features — it replayed from task.init through raw operators, so it saw neither axioms nor numeric fluents.
  • Time budgets overran ~35×. The clock was checked every 256 expansions; under LM-cut one expansion costs tens of milliseconds, so a 0.5s limit took 17s.
  • Timed literals could fire out of order(at 0 (open)) and (at 3 (not (open))) fired backwards left the shop open forever. Caught by a test that expected an unsolvable instance and got a plan.
  • Synthetic actions were charged cost 1, silently inflating every metric involving a preference.

Verification

  • 358 tests (from 92), green on Python 3.9 through 3.12.
  • flake8 and black clean.
  • Demo instances are all grounded, solved and validated by the suite; hanoi is checked against the closed form 2⁵−1.
  • The workbench was driven in a real browser; every generator's output is grounded and solved in tests.

Known limits

  • :continuous-effects is refused, and true temporal concurrency is not modelled. Durative actions compile to a sequential schedule and never overlap, so a plan needing two actions to run at once will not be found. That needs a mutex-aware temporal scheduler (POPF-style), not another compilation — the one remaining gap that is a project rather than a patch.
  • .github/workflows/pages.yml is included but GitHub Pages must be enabled in repository settings before the workbench URL goes live.
  • web/dist is generated and committed so the page works from a plain clone; a test and the Pages workflow fail if it drifts from the sources.

Generated by Claude Code

claude added 3 commits July 27, 2026 17:07
jupyddl could solve PDDL problems but you could never see it work. This adds an
instrumentation layer and everything that falls out of it.

Instrumentation (jupyddl/trace.py)
- SearchObserver protocol with no-op defaults, so subclasses override only what
  they need; TraceRecorder accumulates a serialisable SearchTrace with JSON
  round-trip and bounded memory (adaptive thinning); MultiObserver fans out.
- Every planner emits start/expand/generate/bound/goal/finish. Observers sit
  behind `if observer is not None`, so the default path keeps its zero-overhead
  behaviour, and tests assert an observed search returns the same plan and
  statistics as an unobserved one.
- trace_search() in the high-level API returns (result, trace).

Seeing it
- jupyddl/live.py: a live terminal dashboard with Unicode sparklines, a frontier
  gauge and live counters. Stdlib only -- watching a search costs no dependency.
  Degrades to one-line progress on a non-tty, so it is safe in CI and notebooks.
- jupyddl/viz/: a light/dark chart theme validated for colour-vision-deficiency
  separation, plus search-progress, radial-wavefront, planner-comparison,
  benchmark-dashboard and plan-timeline charts, a notebook LiveSearchPlot, and
  animate_search() for MP4/GIF replays. Charts release their figure after
  writing, so galleries no longer accumulate figures.

Running it in a browser (web/)
- The playground runs this library unmodified under Pyodide in a web worker.
  The core being stdlib-only means there is no wheel to resolve: the sources are
  handed straight to the interpreter. Live charts, an animated wavefront and a
  four-planner race, all computed in the tab.
- web/dist is generated by tools/build_web.py and committed so a plain clone
  works; a test and the Pages workflow fail if it drifts from the sources.

Demo instances (demos/)
- Six instances chosen to stress different parts of the framework: gripper,
  blocksworld8, hanoi (checked against the closed form 2^5-1), logistics
  (action costs), sokoban (static pruning, dead ends) and elevator (conditional
  effects). The bundled examples topped out at 127 expansions, which was too
  small to show a heuristic doing anything.

CLI
- New: `jupyddl animate` and `jupyddl demo`; `--live/--trace/--plot/--tree/
  --plan-plot/--dark` on solve, `--dashboard` on benchmark.

Also
- tools/make_promo.py renders the project's promo video from numbers it measures
  at render time by running the real planners; nothing in it is hand-typed.
- Test suite grows 92 -> 193, green on Python 3.9 through 3.12.
- The viz extra now requires matplotlib>=3.6: the charts use the constrained
  layout engine API, which 3.5 does not have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Meb35zHKsyBkH2sbWoyKMT
The parser handled STRIPS plus a little ADL. It now covers 15 of the 21 PDDL
requirement flags, and refuses the other 6 by name.

Requirements registry (jupyddl/requirements.py)
- One table saying what happens to every flag: native, compiled, partial or
  rejected, each with a reason. The parser validates against it, the CLI prints
  it, the README table is generated from it and the web UI renders it, so the
  documentation cannot drift from the behaviour.
- A rejected flag fails at parse time with an explanation. Silently ignoring a
  requirement yields plans that are wrong rather than absent.

Full ADL conditions
- `or`, `imply`, `exists` and nested `not` in preconditions, goals and `when`
  conditions. The parser emits negation normal form; the grounder expands
  quantifiers over the object pool and distributes to DNF, one operator per
  disjunct, so the search still only ever sees conjunctions.
- Disjunctive goals compile to an artificial goal fact reached by a zero-cost
  operator per disjunct, hidden from the printed plan.

Derived predicates, numerics, time
- `(:derived ...)` axioms are grounded and closed to a least fixpoint after
  every state change, and enter the delete relaxation as zero-cost rules so the
  heuristics stay admissible.
- Numeric fluents: comparisons in preconditions and goals, and assign/increase/
  decrease/scale-up/scale-down effects. Numeric tasks carry a value vector in
  the state; classical tasks keep the bare frozenset and pay nothing.
- Durative actions compile to sequential actions carrying their duration, with
  Task.makespan. Concurrency is explicitly not modelled, and the support table
  says so rather than implying otherwise.

Planners and budgets
- Five more: hill climbing, beam search, Iterated Width, branch and bound, and
  anytime weighted A*.
- max_expansions / time_limit on every planner, the API, the CLI and the
  benchmark harness. A truncated run sets stats.truncated, so "we stopped
  looking" is never reported as "no plan exists" -- and branch and bound, which
  otherwise runs for hours, carries its own default ceiling.

Generators and examples
- jupyddl.generator: seven parametrised domains where the same (kind, size,
  seed) always emits byte-identical PDDL, plus `jupyddl generate`. random-strips
  plants a reachable chain among distractors, because uniformly random operators
  are almost always unsolvable and so useless as a benchmark.
- New demos: rovers (ADL), network (recursive axioms), numeric-transport,
  workshop (temporal), blocksworld12.

Web workbench
- Four views: Solve, Experiment (an instance x configuration sweep sharing one
  grounding per instance, streaming into a sortable table with CSV/JSON export),
  PDDL support, and Generate.

Fixes
- validate_plan replayed from task.init through raw operators, so it saw neither
  derived predicates nor numeric fluents; it now goes through the task.
- Budget checked the clock every 256 expansions. Under LM-cut one expansion can
  cost tens of milliseconds, so a 0.5s limit overran to 17s. It now checks every
  expansion.
- Operator.base_name strips compilation tags, so plans print the action the
  domain author wrote instead of `move(a,b)#2`.

Test suite 204 -> 298, green on Python 3.9 through 3.12.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Meb35zHKsyBkH2sbWoyKMT
… object fluents

Five of the six flags that were refused are now supported, taking coverage from
15 of 21 to 20. All of it is source-to-source: jupyddl/compile.py rewrites these
constructs into the classical core before grounding, so the grounder and the
search engine are untouched and there is still one representation to optimise.

:constraints
- always, at-end, sometime, sometime-before, sometime-after, at-most-once.
- `always phi` becomes a precondition on every action plus a goal conjunct.
  Every state on a trajectory is the initial state, a state an action is taken
  from, or the final state, so those three checks cover all of them.
- sometime / sometime-before use optional monitor actions the planner applies
  when convenient. sometime-after and at-most-once use forced monitors on
  conditional effects instead: a constraint the planner could satisfy by not
  looking would not be a constraint.
- The metric-time forms (within, always-within, hold-after, hold-during) are
  refused by name.

:preferences
- A closing action freezes the state, then each preference is resolved either
  free (if it holds) or at its (is-violated p) weight. Cost-optimal search then
  minimises the metric without knowing what a preference is.
- Freezing first is the point: otherwise a preference could be satisfied halfway
  through, broken, and still paid for. There is a test for exactly that.
- Soft trajectory constraints are refused; goal preferences are the supported
  form.

:timed-initial-literals
- Elapsed time becomes a numeric fluent advanced by action durations. Each
  literal gets a firing action guarded on the clock and a wait action that
  advances it, and every domain action is blocked while a due literal has not
  fired.
- Literals must also fire in time order. Without that, `(at 0 (open))` and
  `(at 3 (not (open)))` could be fired backwards and leave the shop open
  forever -- caught by a test that expected an unsolvable instance and got a
  plan.

:object-fluents
- Compiled to a predicate plus a uniqueness rule; assign clears the old value
  first, so the function cannot quietly become a relation. Nested-term use is
  refused in favour of the equality form.

:duration-inequalities
- Bounds are collected and the shortest feasible duration chosen, which is
  makespan-optimal given no concurrency and no continuous change. Strict < and >
  are refused: they have no shortest feasible value.

Also
- Task.makespan replays the clock when a task has one. Waiting for a timed
  literal advances time without any action taking that long, so summing
  durations under-reported the end time.
- Synthetic actions declare a zero cost. Grounding charges 1 for an action with
  no cost effect, which silently inflated every metric involving a preference.
- New demos: errands (preferences + constraints, where the optimal plan
  deliberately skips an errand and pays the penalty) and timed-market.
- Tests 298 -> 358, green on Python 3.9 through 3.12.

Still not supported: :continuous-effects, and true temporal concurrency.
Durative actions compile to a sequential schedule and never overlap, so a plan
needing two actions to run at once will not be found. That needs a mutex-aware
temporal scheduler, not another compilation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Meb35zHKsyBkH2sbWoyKMT
@cursor

cursor Bot commented Jul 28, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@mergify

mergify Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants