diff --git a/.docs/learned-heuristics.md b/.docs/learned-heuristics.md index e1dc85f..7097a9c 100644 --- a/.docs/learned-heuristics.md +++ b/.docs/learned-heuristics.md @@ -96,7 +96,10 @@ means nothing. `rank_weight=0.8` splits the objective; 0.8 is a reasonable default, not a tuned optimum. The metric we select checkpoints on is **top-1 ranking accuracy**, not -validation MAE, for the same reason. +validation MAE, for the same reason. It is measured on the held-out instances, +and a tie with `k` siblings earns `1/(k+1)` — its chance of winning a random +tie-break — so a model that scores every state alike rates as guessing, not as +perfect. ### Not admissible, and it does not pretend to be @@ -112,7 +115,8 @@ become unsound. Train on a small ladder, test on instances two to three times larger with different seeds. Planner is `gbfs` throughout, budget 30 000 expansions / 30 s. Coverage is the fraction solved; expansions and seconds are means over -solved instances. +solved instances — a convention that flatters low coverage, and that the code +no longer uses (see [Corrections, 2026-11-03](#corrections-2026-11-03)). ### blocksworld — trained on 3–6 blocks, tested on 9–13 @@ -126,8 +130,8 @@ solved instances. Against `hff`: **3.8× fewer expansions, 15× faster, and slightly cheaper plans.** Corpus construction took 0.5 s, training 0.1 s, search-cost tuning about 50 s. -Held-out MAE 0.92, top-1 accuracy 0.923. The model has 1025 parameters over 14 -features. +Held-out MAE 0.92, top-1 accuracy 0.923 (the top-1 was not held out; see the +corrections below). The model has 1025 parameters over 14 features. The tuned row is what this prints, end to end, in about a minute: @@ -161,7 +165,8 @@ a correction to an earlier claim about *why* the tuned model is better, are in The cleanest win: half the expansions of `hff`, 5.6× faster, and *better plans*. Top-1 accuracy 1.000 — gripper's structure is almost entirely captured by "how many balls are still in the wrong room", which is exactly what these features -encode. +encode. (As measured here that 1.000 was not evidence of anything, and this +table was built from duplicated instances; see the corrections below.) Note `blind`'s plan cost of 25.0, the lowest in the table. It solved only 40% of the instances and its search is effectively breadth-first on those, so it finds @@ -180,6 +185,63 @@ first column. **A clear loss.** `hff` expands 6× fewer nodes and finds plans 42% cheaper. The learned heuristic beats `goalcount` and nothing else. +### Corrections, 2026-11-03 + +A review of `jupyddl.learn` found bugs that touched figures on this page. The +figures above are left exactly as measured — they match +`.docs/assets/rl-data.json`, the Research view and the video — and this is what +each one actually measured. + +- **Top-1 accuracy was neither held out nor tie-safe.** It was computed over + every ranking group in the corpus, training instances included, while + `jupyddl learn` printed it as held out; and a sibling that scored level with + the successor the plan took counted as a correct ranking. Checkpoint + selection read the same number. The tie rule can only push it up, and + training groups usually do. Re-run with the fix — same corpora, seeds and 60 + epochs, top-1 over the held-out instances' groups only, a tie with `k` + siblings credited `1/(k+1)` — which takes seconds: + + | | published | re-measured | held-out groups | + |---|---|---|---| + | blocksworld | 0.923 | 0.929 | 28 of 101 | + | logistics | 0.656 | 0.667 | 42 of 157 | + | gripper, old generator, two seeds per size | 1.000 | 1.000 | 30 of 80 | + + The old code reproduces each published figure on these corpora. Gripper's + protocol is not recorded beyond the sizes, so its row uses `jupyddl learn`'s + default of two seeds per size. + + The values barely move, but the checkpoint does: blocksworld now keeps epoch + 12 rather than 20, and its held-out MAE is 1.16 rather than 0.92; logistics + keeps epoch 22 rather than 9. **Every other learned-heuristic figure on this + page and in [rl-for-search.md](rl-for-search.md) was measured on the + checkpoint the old rule kept.** They are true of that model; the fixed code + trains a different one, and none of them has been re-measured. +- **Gripper's 1.000 could not have come out any other way.** In that corpus, 59 + of the 80 groups held a sibling identical in features and step cost to the + successor taken — picking a ball up with the other hand gives a different + state that the features cannot tell apart — so a model that outputs a + constant also scored 1.000. Worse, the gripper generator ignored its seed: + the "held-out" instances were copies of training ones, and since `--evaluate` + draws two seeds per size, the ten test instances in the table were five, + each counted twice. The means are unchanged by that, but rest on half the + sample claimed; a `--cem` run on gripper would also have tuned and selected + on identical instances. With identical siblings dropped, top-1 is still 1.000 + on the old instances and on 27 groups of genuinely unseen ones from the + fixed generator, so the claim survives; the published number was simply not + evidence for it. The table cannot be re-run as it stands, because the same + command now generates different instances. +- **Means over solved runs flattered low coverage.** The imitation-only + blocksworld row's 366 averages the nine instances it solved and drops the one + it could not finish in 30 000 expansions; its plan cost of 49.1 is over the + same nine. Charging that failure as `search_cost` does, at twice the budget, + its mean is 6 329. On the nine instances every configuration solved, the + tuned model averages 129, not 137 — computed from the per-instance table in + [rl-for-search.md](rl-for-search.md). `blind`'s 20 198 on gripper is the same + flattery, a mean over the instances it happened to solve; not re-measured. + `summarise_transfer` now charges failures that way and reports plan cost + only over instances every configuration with any coverage solved. + ## Why logistics fails, precisely This is the most useful result in the set, because the mechanism is exact diff --git a/.docs/rl-for-search.md b/.docs/rl-for-search.md index 9b8c2d3..76ec5fd 100644 --- a/.docs/rl-for-search.md +++ b/.docs/rl-for-search.md @@ -133,7 +133,9 @@ on a small instance. | after CEM | **100** | **137 expansions, 1.00 coverage** | CEM fixed the coverage failure imitation left behind, then shaved expansions: -2.7× fewer nodes on instances no stage of training ever saw. +2.7× fewer nodes on instances no stage of training ever saw. (The two means are +over different instance sets; see [the second +correction](#a-second-correction-2026-11-03).) ### A correction, and what the numbers actually say @@ -203,6 +205,38 @@ The general lesson is the ordinary one, which the derivative-free framing made easy to forget: **change one thing at a time, and look at the distribution before believing the mean.** `learn_heuristic` always passes one. +### A second correction, 2026-11-03 + +A review of the learning code turned up three more things that bear on this +page. Nothing above has been re-run — the expensive measurement is not repeated +for a correction note — so the figures stand as measured, and this says what +they measured. + +- **"366 expansions, 0.90 coverage" is a mean over the nine instances + imitation solved.** The tenth, `blocksworld-13-7777`, contributed nothing + rather than 30 000, and the 137 it is compared with averages all ten. On the + nine both solved, the per-instance table gives 129 for σ=0.15, so the + like-for-like improvement is 366 → 129, 2.8× rather than 2.7×; charging the + unsolved instance as `search_cost` does (twice the budget) puts imitation's + own mean at 6 329. The bias ran in imitation's favour, which is the + dangerous direction: it is how a heuristic that gives up looks cheap. + `summarise_transfer` averaged over each heuristic's own solved runs; it now + charges failures and reports coverage beside every mean. +- **Every model here descends from a checkpoint chosen by a broken score.** + Imitation kept the epoch with the best top-1 measured over *training* + instances, with a tie counted as a correct ranking. Re-run with the fix on + the same corpus, blocksworld keeps epoch 12 rather than 20 (details and the + re-measured top-1 in [learned-heuristics.md](learned-heuristics.md#corrections-2026-11-03)). + The CEM history, the per-instance table, the σ comparison and the headroom + measurements below are true of the model the old rule kept; the fixed code + starts CEM from a different one. Not re-measured. +- **DAgger's retraining split leaked.** Relabelled states are filed under + `X#dagger1`, and the split keyed on the full name, so a training instance's + own relabelled states could sit in validation. That fed only the MAE + tie-break in checkpoint selection, not the search costs quoted (89 → 15 and + 89 → 164, which `search_cost` measures directly), but it made the validation + MAE of those rounds optimistic. The split now keys on the name before `#`. + ## The three things that decide whether this works ### Start from the imitation solution diff --git a/CHANGELOG.md b/CHANGELOG.md index cc2fb36..4260eb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -141,6 +141,60 @@ plans because it replays them through the same model. They are now pinned by - `black` is pinned to one major version in both the dev extra and the `format` workflow, which installed whatever was newest; Renovate now pins third-party actions to commit digests. +- **Learned-heuristic top-1 was neither held out nor tie-safe.** A sibling + scoring level with the successor the plan took counted as a correct ranking, + so a model that outputs a constant scored 1.000 — and checkpoint selection, + which reads top-1, could prefer a collapsed epoch. `Corpus.split` also gave + the held-out side no ranking groups, so the "held out" top-1 `jupyddl learn` + printed was measured over training instances. A tie with `k` siblings now + earns `1/(k+1)`, a sibling identical to the chosen successor in features and + step cost is not recorded at all, and selection and the reported top-1 use + the held-out groups. The published figures came from the old rule; see the + dated corrections in `.docs/learned-heuristics.md` and + `.docs/rl-for-search.md`. +- **A DAgger or bootstrap state could validate against its own instance.** + `split` treated `X#dagger1` as an instance apart from `X`; it now groups on + the name before the `#`. +- **`summarise_transfer` averaged each heuristic over the runs it solved**, so + one that solved only the easy instance looked many times better than one that + solved both. An unsolved run is now charged twice the budget, as + `search_cost` charges it, and plan cost compares only instances every + configuration with any coverage solved. The imitation-only blocksworld mean + the docs quote as 366 is 6 329 under that rule. +- **A malformed learned heuristic loaded, then gave one answer with NumPy and + another without.** A network whose input width is not the feature vector's, + a ragged row, a `sizes` field that does not describe the weights, a + non-finite value or a non-positive scale is now a `ValueError` on load. + `MLP.set_flat` checks the length before writing anything, instead of leaving + a wrong-length vector half-applied. +- **Every negated fact shared one feature, `not`.** Complement facts now key on + the predicate they negate (`not-sampled`), so rovers and workshop get a slot + per negated predicate. A model trained on a domain with negative + preconditions before this sees different features now; retrain it. +- `train()` with a pure ranking objective and no ranking groups returned its + random initialisation as a trained model; it now raises. `learn_heuristic` + spent a generator passed as `sizes` on its first read, recording `[]` and + raising under `cem_iterations`; size arguments are now read once, on entry. +- **`gripper` and `workshop` ignored their seed.** Twenty seeds gave one + instance, so a split by seed held out copies of training instances, and the + RL stage's tuning and validation sets were the same problems. The seed now + decides which room each ball and the robot start in (every ball still has to + change rooms), and how far along the pipeline each part starts. **Every + generated gripper and workshop instance changes**; `demos/workshop` is the one + in which every part starts raw, and `generate` no longer reproduces it. +- **Generators could emit an instance solved before it starts.** `blocksworld` + compared tower lists in order and skipped the check for two blocks, where 75 + of the first 200 seeds came out trivial; `random-strips` could draw a goal + that already held. Both now re-draw only in that case, so every instance that + was not trivial is byte-identical to before. +- **The benchmark harness merged configurations that differed in planner + arguments** — `wastar` at weights 1 and 5 summarised as one entry with twice + the instances — and recorded `("gbfs", None)` as heuristic `""` although it + ran `hff`. Rows carry a `config` label with the arguments + (`wastar/hff(weight=5.0)`, also a new CSV column), which `summarize` and the + dashboard group on; a repeated config is suffixed `#2`; and `heuristic` is + what `solve_task` actually ran, worked out by the new + `jupyddl.api.resolve_heuristic`. ## [2.4.0] diff --git a/README.md b/README.md index 2b88c26..0b0d7b3 100644 --- a/README.md +++ b/README.md @@ -471,12 +471,14 @@ and to make the difference between planners obvious: | `errands` | **preferences + constraints**: soft goals priced by a metric | 10 | | `timed-market` | **timed initial literals**: the plan waits for opening time | 4 | -Two of them are byte-for-byte generator output — `blocksworld12` is -`generate blocksworld --size 12 --seed 3`, `workshop` is -`generate workshop --size 3 --seed 1`. The rest were either hand-written +`blocksworld12` is byte-for-byte generator output: +`generate blocksworld --size 12 --seed 3`. `workshop` was +`generate workshop --size 3 --seed 1` until that generator's seed began to +decide how far along the pipeline each part starts; it is the instance in which +every part starts raw. The rest were either hand-written (`sokoban`, `hanoi`, `elevator`, `errands`, `timed-market`, and `network`, whose recursive axiom is the point of it) or generated and then edited by hand, -so `generate` will produce something equivalent but not identical. +so `generate` will produce something similar but not identical. The `pddl-examples` git submodule supplies the smaller instances used by the parser and grounder tests. diff --git a/jupyddl/api.py b/jupyddl/api.py index bf494d5..45f5fa1 100644 --- a/jupyddl/api.py +++ b/jupyddl/api.py @@ -37,20 +37,30 @@ def solve_task( needs a heuristic means ``blind``. """ planner = make_planner(search, **planner_kwargs) - heur = None - name = heuristic - if name == "none": - name = "blind" if planner.requires_heuristic else None - elif not name and planner.requires_heuristic: - # An optimal planner gets an admissible default: hff would quietly - # cost it its guarantee. - name = "lmcut" if planner.optimal else "hff" - if name: - heur = make_heuristic(name, task) + name = resolve_heuristic(planner, heuristic) + heur = make_heuristic(name, task) if name else None budget = make_budget(max_expansions, time_limit) return planner.search(task, heur, observer=observer, budget=budget) +def resolve_heuristic(planner, heuristic): + """The heuristic :func:`solve_task` actually runs ``planner`` with. + + ``planner`` is a planner instance; ``heuristic`` is what the caller asked + for. Returns a registry name or spec, the heuristic object passed in, or + ``None`` for no heuristic at all. Anything that records which heuristic + produced a result should ask this rather than echo the request, which for + ``None`` and ``"none"`` names something other than what ran. + """ + if heuristic == "none": + return "blind" if planner.requires_heuristic else None + if not heuristic and planner.requires_heuristic: + # An optimal planner gets an admissible default: hff would quietly + # cost it its guarantee. + return "lmcut" if planner.optimal else "hff" + return heuristic or None + + def solve( domain_path: str, problem_path: str, diff --git a/jupyddl/benchmark.py b/jupyddl/benchmark.py index 639f10a..88e934a 100644 --- a/jupyddl/benchmark.py +++ b/jupyddl/benchmark.py @@ -16,8 +16,9 @@ from dataclasses import asdict, dataclass from typing import Optional -from .api import build_task, solve_task, validate_plan +from .api import build_task, resolve_heuristic, solve_task, validate_plan from .parser import PDDLError +from .search import make_planner @dataclass @@ -42,6 +43,9 @@ class BenchmarkRow: runtime: float error: str = "" truncated: bool = False + #: The configuration, planner arguments included (``wastar/hff(weight=5)``). + #: ``planner`` and ``heuristic`` alone cannot tell two weights apart. + config: str = "" def discover_instances(root: str) -> list: @@ -55,50 +59,107 @@ def discover_instances(root: str) -> list: return instances +def config_label(planner: str, heuristic: str = "", kwargs=None) -> str: + """Name a configuration: ``planner/heuristic``, plus any planner arguments. + + >>> config_label("wastar", "hff", {"weight": 5}) + 'wastar/hff(weight=5)' + >>> config_label("bfs") + 'bfs' + """ + label = f"{planner}/{heuristic}" if heuristic else planner + if kwargs: + label += "(" + ",".join(f"{k}={v}" for k, v in sorted(kwargs.items())) + ")" + return label + + +def row_label(row) -> str: + """The configuration a row belongs to, for rows that predate ``config``.""" + return row.config or config_label(row.planner, row.heuristic) + + +def _heuristic_label(heuristic) -> str: + if heuristic is None: + return "" + if isinstance(heuristic, str): + return heuristic + return getattr(heuristic, "name", None) or type(heuristic).__name__ + + +def _plan_configs(configs) -> list: + """Unpack each config and work out what will actually run, and its label. + + The heuristic recorded is the one :func:`~jupyddl.api.solve_task` will run, + not the one asked for: ``("gbfs", None)`` runs ``hff`` and used to be + recorded as ``""``, and ``"none"`` runs ``blind`` for a planner that needs + a heuristic. Two config entries never share a label — a repeated one is + suffixed ``#2`` — so :func:`summarize` cannot merge them. + """ + planned = [] + seen: dict = {} + for cfg in configs: + planner, requested = cfg[0], cfg[1] + kwargs = dict(cfg[2]) if len(cfg) > 2 and cfg[2] else {} + try: + ran = resolve_heuristic(make_planner(planner, **kwargs), requested) + except (ValueError, TypeError): + ran = requested # an unknown planner or argument: the run records it + ran = _heuristic_label(ran) + label = config_label(planner, ran, kwargs) + seen[label] = seen.get(label, 0) + 1 + if seen[label] > 1: + label = f"{label}#{seen[label]}" + planned.append((planner, requested, kwargs, ran, label)) + return planned + + +def _failed_row(instance, planner, heuristic, label, exc) -> BenchmarkRow: + return BenchmarkRow( + instance, + planner, + heuristic, + False, + False, + None, + None, + 0, + 0, + 0, + 0.0, + f"{type(exc).__name__}: {exc}", + config=label, + ) + + def run_benchmark(instances, configs, max_expansions=None, time_limit=None) -> list: """Run each ``(planner, heuristic[, kwargs])`` config on each instance. ``configs`` entries are ``(planner_name, heuristic_name_or_None)`` or - ``(planner_name, heuristic_name_or_None, planner_kwargs)``. + ``(planner_name, heuristic_name_or_None, planner_kwargs)``. Each row + records the heuristic that actually ran and a ``config`` label that + includes the planner arguments, so ``wastar`` at two weights stays two + configurations. ``max_expansions`` and ``time_limit`` bound every individual run, which is what keeps one pathological instance from stalling a whole benchmark. A run that stops on its budget is recorded with ``truncated=True`` and does not count towards coverage -- "we stopped looking" is not "no plan exists". """ + planned = _plan_configs(configs) rows: list = [] for inst in instances: try: task = build_task(inst.domain, inst.problem) except (PDDLError, ValueError) as exc: - for cfg in configs: - planner, heuristic = cfg[0], (cfg[1] or "") - rows.append( - BenchmarkRow( - inst.name, - planner, - heuristic, - False, - False, - None, - None, - 0, - 0, - 0, - 0.0, - f"{type(exc).__name__}: {exc}", - ) - ) + for planner, _, _, ran, label in planned: + rows.append(_failed_row(inst.name, planner, ran, label, exc)) continue - for cfg in configs: - planner = cfg[0] - heuristic = cfg[1] - kwargs = cfg[2] if len(cfg) > 2 else {} + for planner, requested, kwargs, ran, label in planned: try: result = solve_task( task, planner, - heuristic, + requested, max_expansions=max_expansions, time_limit=time_limit, **kwargs, @@ -108,7 +169,7 @@ def run_benchmark(instances, configs, max_expansions=None, time_limit=None) -> l BenchmarkRow( inst.name, planner, - heuristic or "", + ran, result.solved, valid, result.cost, @@ -119,25 +180,11 @@ def run_benchmark(instances, configs, max_expansions=None, time_limit=None) -> l round(result.stats.runtime, 6), "", result.stats.truncated, + label, ) ) except Exception as exc: # keep the benchmark going on a single failure - rows.append( - BenchmarkRow( - inst.name, - planner, - heuristic or "", - False, - False, - None, - None, - 0, - 0, - 0, - 0.0, - f"{type(exc).__name__}: {exc}", - ) - ) + rows.append(_failed_row(inst.name, planner, ran, label, exc)) return rows @@ -155,10 +202,15 @@ def to_csv(rows, path: str) -> None: def summarize(rows) -> dict: - """Aggregate coverage and totals per ``planner/heuristic`` configuration.""" + """Aggregate coverage and totals per configuration. + + Keyed on :func:`row_label`, which carries the planner's arguments. Keyed + on ``planner/heuristic`` alone, ``wastar`` at weights 1 and 5 merged into + one entry claiming twice the instances the benchmark had. + """ summary: dict = {} for row in rows: - key = f"{row.planner}/{row.heuristic}" if row.heuristic else row.planner + key = row_label(row) agg = summary.setdefault( key, {"coverage": 0, "expanded": 0, "runtime": 0.0, "instances": 0} ) diff --git a/jupyddl/cli.py b/jupyddl/cli.py index 0b52d97..e593105 100644 --- a/jupyddl/cli.py +++ b/jupyddl/cli.py @@ -557,11 +557,22 @@ def _cmd_learn(args) -> int: ) print(f" parameters {bundle.model.num_parameters}") metrics = bundle.metrics - print( - f" held out MAE {metrics.get('mae', 0):.2f}" - f" top-1 {metrics.get('top1', 0):.3f}" - f" over {metrics.get('held_out_instances', 0)} instances" - ) + # Say "held out" only of what was: a corpus of one instance, or held-out + # instances whose plans never branched, leave nothing but training data. + held = metrics.get("held_out_instances", 0) + if held: + print(f" held out MAE {metrics.get('mae', 0):.2f} over {held} instances") + else: + print(f" training MAE {metrics.get('mae', 0):.2f} (nothing was held out)") + if "top1" not in metrics: + print(" top-1 n/a (the corpus has no ranking groups)") + else: + where = ( + "held out" + if metrics.get("ranking_held_out") + else "on training groups; none were held out" + ) + print(f" top-1 {metrics['top1']:.3f} ({where})") print(f"\nUse it:\n jupyddl solve -s gbfs -H learned:{output}") if args.evaluate: @@ -577,10 +588,17 @@ def _cmd_learn(args) -> int: ) for name in ["learned"] + [k for k in summary if k != "learned"]: agg = summary[name] + cost = "—" if agg["mean_cost"] is None else f"{agg['mean_cost']:.1f}" print( f" {name:<12}{agg['coverage']:>10.2f}{agg['mean_expanded']:>12.0f}" - f"{agg['mean_seconds']:>10.3f}{agg['mean_cost']:>8.1f}" + f"{agg['mean_seconds']:>10.3f}{cost:>8}" ) + common = summary["learned"]["common"] + print( + " expanded and seconds are over every instance, an unsolved one " + "charged double;\n" + f" cost is over the {common} solved by every heuristic that solved any" + ) return 0 @@ -740,11 +758,13 @@ def _print_stats(result) -> None: def _print_summary(summary) -> None: - print(f"{'config':<20}{'coverage':>12}{'expanded':>12}{'runtime(s)':>12}") - print("-" * 56) + # A label carries its planner arguments now, so it can outgrow a fixed column. + width = max([20] + [len(key) + 2 for key in summary]) + print(f"{'config':<{width}}{'coverage':>12}{'expanded':>12}{'runtime(s)':>12}") + print("-" * (width + 36)) for key, agg in summary.items(): cov = f"{agg['coverage']}/{agg['instances']}" - print(f"{key:<20}{cov:>12}{agg['expanded']:>12}{agg['runtime']:>12.3f}") + print(f"{key:<{width}}{cov:>12}{agg['expanded']:>12}{agg['runtime']:>12.3f}") def main(argv=None) -> int: diff --git a/jupyddl/generator.py b/jupyddl/generator.py index d31997a..d2776e5 100644 --- a/jupyddl/generator.py +++ b/jupyddl/generator.py @@ -103,20 +103,32 @@ def _tower_facts(towers) -> list: return facts +def _goal_facts(towers) -> list: + facts = [] + for tower in towers: + facts.append(f"(ontable {tower[0]})") + for lower, upper in zip(tower, tower[1:]): + facts.append(f"(on {upper} {lower})") + return facts + + def generate_blocksworld(size: int = 6, seed: int = 0): - """``size`` blocks in random towers, to be rearranged into other towers.""" + """``size`` blocks in random towers, to be rearranged into other towers. + + The goal is re-drawn until some goal fact is false in the initial state, + so no seed yields an instance that is solved before it starts. The test + used to compare the two tower lists in order, which missed the same towers + listed differently, and was skipped for two blocks — where 75 of the first + 200 seeds came out already solved. + """ rng = random.Random(seed) blocks = _objects("b", max(2, size)) start = _random_towers(blocks, rng) + initial = set(_tower_facts(start)) goal_towers = _random_towers(blocks, rng) - while goal_towers == start and len(blocks) > 2: + while set(_goal_facts(goal_towers)) <= initial: goal_towers = _random_towers(blocks, rng) - - goal_facts = [] - for tower in goal_towers: - goal_facts.append(f"(ontable {tower[0]})") - for lower, upper in zip(tower, tower[1:]): - goal_facts.append(f"(on {upper} {lower})") + goal_facts = _goal_facts(goal_towers) problem = f"""\ ;; {len(blocks)} blocks, seed {seed} -- generated by jupyddl.generator @@ -164,22 +176,27 @@ def generate_blocksworld(size: int = 6, seed: int = 0): def generate_gripper(size: int = 4, seed: int = 0, grippers: int = 2): - """``size`` balls to move from room A to room B. Plan length grows linearly. + """``size`` balls, each to be carried to the room it does not start in. - Unlike every other generator here, ``seed`` does not vary the instance: - in gripper every ball starts in room A and must end in room B, so - ``size`` and ``grippers`` determine the problem completely. The seed - reaches only the problem name, which keeps generated files distinct. + The seed decides which room each ball starts in and which room the robot + starts in. Every ball has to change rooms, so plan length still grows + linearly with ``size`` and no instance is solved before it starts. - The consequence is worth stating because it is easy to miss: a - train/test split that varies the seed and holds the size fixed produces - the *same instance* twice for this domain. Vary ``size`` instead. + This is a generalisation of the IPC instance, in which every ball starts + in room A. That family has exactly one instance per size, and it is what + this generator used to emit for every seed — the seed reached only the + problem name — so a train/test split that varied the seed at a fixed + size held out a copy of a training instance. """ + rng = random.Random(seed) balls = _objects("ball", max(1, size)) hands = _objects("gripper", max(1, grippers)) - init = ["(at-robby rooma)"] + rooms = ("rooma", "roomb") + start = {ball: rng.choice(rooms) for ball in balls} + init = [f"(at-robby {rng.choice(rooms)})"] init += [f"(free {g})" for g in hands] - init += [f"(at {b} rooma)" for b in balls] + init += [f"(at {b} {start[b]})" for b in balls] + goals = [f"(at {b} {rooms[1 - rooms.index(start[b])]})" for b in balls] problem = f"""\ ;; {len(balls)} balls, {len(hands)} grippers, seed {seed} @@ -192,7 +209,7 @@ def generate_gripper(size: int = 4, seed: int = 0, grippers: int = 2): (:init {chr(10).join(' ' + f for f in init).strip()}) (:goal (and - {chr(10).join(f' (at {b} roomb)' for b in balls).strip()}))) + {chr(10).join(' ' + g for g in goals).strip()}))) """ return GRIPPER_DOMAIN, problem @@ -486,14 +503,26 @@ def generate_numeric_transport(size: int = 2, seed: int = 0, locations: int = 4) def generate_temporal_workshop(size: int = 2, seed: int = 0): - """``size`` parts through a cut/drill/paint/inspect pipeline, with durations.""" + """``size`` parts through a cut/drill/paint/inspect pipeline, with durations. + + The seed decides how far along the pipeline each part already is — raw, + cut, drilled or painted. None starts finished, so every instance has work + left in it. (The seed used to reach only the problem name: every part + started raw, and twenty seeds gave one instance.) + """ + rng = random.Random(seed) parts = _objects("part", max(1, size)) + stages = ("cut", "drilled", "painted") + init = [] + for part in parts: + done = rng.randint(0, len(stages)) + init += [f"({stage} {part})" for stage in stages[:done]] problem = f"""\ ;; {len(parts)} parts, seed {seed} (define (problem workshop-{len(parts)}-{seed}) (:domain workshop) (:objects {' '.join(parts)} - part) - (:init ) + (:init {' '.join(init)}) (:goal (and {chr(10).join(f' (finished {p})' for p in parts).strip()}))) """ @@ -515,7 +544,7 @@ def generate_random_strips( makes for a useless benchmark. Instead this plants a random *chain*: each step in the chain has an action that turns the previous state into the next, so a plan of known length exists. The remaining actions are noise the - planner has to search past. + planner has to search past. At least one goal fact is false initially. """ rng = random.Random(seed) size = max(4, size) @@ -548,6 +577,26 @@ def generate_random_strips( state.difference_update(deleted) goal_facts = sorted(rng.sample(sorted(state), min(goal_size, len(state)))) + if not goal_facts or set(goal_facts) <= init_facts: + # Every fact drawn already holds initially, which makes the instance + # solved before it starts. Swap one for a fact the chain achieved. Only + # this branch draws from ``rng``, so every instance that never needed + # it is byte-identical to what this generator always produced. + fresh = sorted(state - init_facts) + if not fresh: + # The chain undid itself. One more planted step, adding a fact the + # initial state lacks, keeps the goal reachable and non-trivial. + added = rng.choice(sorted(set(facts) - init_facts)) + precondition = sorted(rng.sample(sorted(state), min(2, len(state)))) + operators.append((f"solve{len(operators) + 1}", precondition, [added], [])) + state.add(added) + fresh = [added] + replacement = rng.choice(fresh) + if goal_facts: + goal_facts[rng.randrange(len(goal_facts))] = replacement + else: + goal_facts = [replacement] + goal_facts = sorted(goal_facts) # --- distractors ------------------------------------------------------- for index in range(max(0, actions - len(operators))): diff --git a/jupyddl/learn/dataset.py b/jupyddl/learn/dataset.py index 483787d..7fff4fb 100644 --- a/jupyddl/learn/dataset.py +++ b/jupyddl/learn/dataset.py @@ -61,6 +61,29 @@ def task_from_state(task, state): return _replace(task, init=facts_of(state), init_values=values_of(state)) +def instance_family(name: str) -> str: + """The instance a sample belongs to, ignoring the stage that labelled it. + + >>> instance_family("blocksworld-05-1#dagger2") + 'blocksworld-05-1' + """ + return name.split("#", 1)[0] + + +def _random_split(items, validation: float, rng): + """``items`` shuffled into a training share and a held-out remainder.""" + if not items: + return [], [] + order = list(range(len(items))) + rng.shuffle(order) + cut = max(1, int(len(order) * (1 - validation))) + keep = set(order[:cut]) + return ( + [item for i, item in enumerate(items) if i in keep], + [item for i, item in enumerate(items) if i not in keep], + ) + + @dataclass class Sample: """One state, its feature vector, and the cost-to-go we believe it has.""" @@ -81,6 +104,10 @@ class RankingGroup: to travel with the vector because the comparison GBFS makes at expansion time is between ``h(s')`` values, but the comparison that is *correct* is between ``c(s, s') + h*(s')`` — with non-uniform action costs those differ. + + :func:`samples_from_plan` leaves out any sibling whose features and step + cost equal the chosen successor's: no model can rank the pair, so it is + not evidence of anything. """ chosen: tuple @@ -112,30 +139,42 @@ def split(self, validation: float = 0.2, seed: int = 0): on the same plan have nearly identical features and nearly identical targets; splitting at random puts one in train and one in validation and reports a validation score that measures memorisation. + + An instance is its name up to the first ``#``. DAgger and bootstrapping + file the states they label under ``X#dagger1``, ``X#boot2`` and so on, + and those lie on and around ``X``'s own plan: keyed on the full name, + ``X`` could train while ``X#dagger1`` validated. + + Ranking groups are split with their samples, so the held-out side has + both and ranking accuracy can be measured on instances training never + saw. Dropping them — what this used to do — left nothing to measure + top-1 on but the training groups. """ - instances = sorted({s.instance for s in self.samples}) + instances = sorted({instance_family(s.instance) for s in self.samples}) if len(instances) < 2: - # Nothing to hold out by instance; fall back to a sample split and + # Nothing to hold out by instance; fall back to a random split and # accept that the score is optimistic. rng = random.Random(seed) - order = list(range(len(self.samples))) - rng.shuffle(order) - cut = max(1, int(len(order) * (1 - validation))) - train_idx = set(order[:cut]) - train = [s for i, s in enumerate(self.samples) if i in train_idx] - val = [s for i, s in enumerate(self.samples) if i not in train_idx] + train, val = _random_split(self.samples, validation, rng) + train_groups, val_groups = _random_split(self.groups, validation, rng) return ( - Corpus(self.space, train, self.groups), - Corpus(self.space, val, []), + Corpus(self.space, train, train_groups), + Corpus(self.space, val, val_groups), ) rng = random.Random(seed) rng.shuffle(instances) cut = max(1, int(len(instances) * (1 - validation))) held = set(instances[cut:]) - train = [s for s in self.samples if s.instance not in held] - val = [s for s in self.samples if s.instance in held] - groups = [g for g in self.groups if g.instance not in held] - return Corpus(self.space, train, groups), Corpus(self.space, val, []) + train = [s for s in self.samples if instance_family(s.instance) not in held] + val = [s for s in self.samples if instance_family(s.instance) in held] + train_groups = [ + g for g in self.groups if instance_family(g.instance) not in held + ] + val_groups = [g for g in self.groups if instance_family(g.instance) in held] + return ( + Corpus(self.space, train, train_groups), + Corpus(self.space, val, val_groups), + ) def target_stats(self) -> dict: if not self.samples: @@ -261,20 +300,22 @@ def samples_from_plan( successor = task.apply(other, state) if facts_of(successor) == facts_of(states[i + 1]): continue # a different operator reaching the same state - siblings.append((successor, float(other.cost))) + sibling = (bound(successor), float(other.cost)) + if sibling == chosen: + # A different state the features cannot tell apart, at the + # same step cost — gripper's two grippers, say. No model + # over these features can put one ahead of the other, so + # the pair teaches nothing, and kept it let a tie count as + # a correct ranking. + continue + siblings.append(sibling) if not siblings: continue # A branching factor in the hundreds would make the ranking loss # dominate the epoch; a sample of the siblings carries the signal. if len(siblings) > max_siblings: siblings = rng.sample(siblings, max_siblings) - groups.append( - RankingGroup( - chosen, - [(bound(s), c) for s, c in siblings], - instance, - ) - ) + groups.append(RankingGroup(chosen, siblings, instance)) return samples, groups diff --git a/jupyddl/learn/features.py b/jupyddl/learn/features.py index a0ef39f..0486b57 100644 --- a/jupyddl/learn/features.py +++ b/jupyddl/learn/features.py @@ -37,18 +37,29 @@ # atom its own slot, and the feature vector stops being size-invariant while # still looking perfectly reasonable. _SYMBOL = re.compile(r"^\(?\s*([^\s()]+)") +# Grounding adds a complement fact ``(not (p a))`` for each atom a negative +# precondition reads. Through ``_SYMBOL`` alone they all key on ``not``, which +# puts the negation of every predicate in the domain into one slot. +_NEGATED = re.compile(r"^\(\s*not\s*\(\s*([^\s()]+)") def predicate_of(fact_name: str) -> str: """The predicate symbol of a grounded fact string. + A complement fact keys on the predicate it negates, prefixed ``not-``. + >>> predicate_of("(on b1 b2)") 'on' >>> predicate_of("(handempty)") 'handempty' >>> predicate_of("move(a,b)") 'move' + >>> predicate_of("(not (sampled wp1))") + 'not-sampled' """ + negated = _NEGATED.match(fact_name) + if negated: + return "not-" + negated.group(1) match = _SYMBOL.match(fact_name) if not match: return fact_name diff --git a/jupyddl/learn/heuristic.py b/jupyddl/learn/heuristic.py index 3c7e4c8..990c75e 100644 --- a/jupyddl/learn/heuristic.py +++ b/jupyddl/learn/heuristic.py @@ -10,6 +10,7 @@ from __future__ import annotations import json +import math from dataclasses import dataclass, field from ..heuristics.base import Heuristic @@ -46,16 +47,55 @@ def to_dict(self) -> dict: @classmethod def from_dict(cls, data: dict) -> "HeuristicBundle": + """Rebuild a bundle, refusing one whose parts do not fit together. + + Every check here is for a file that would otherwise load and then + mis-predict without a word — the failure this class exists to prevent. + A network whose input width is not the feature vector's length is the + main one: the pure-Python path silently drops the excess on either + side, so the same file gave one heuristic with NumPy installed (which + raises) and a different, wrong one without. + """ + if not isinstance(data, dict): + raise ValueError("a learned-heuristic bundle is a JSON object") version = data.get("format", 0) if version != FORMAT_VERSION: raise ValueError( f"unsupported learned-heuristic format {version}; " f"this build reads version {FORMAT_VERSION}" ) + try: + space_data, model_data = data["space"], data["model"] + vocabulary = space_data["vocabulary"] + except (KeyError, TypeError): + raise ValueError( + "a learned-heuristic bundle needs 'space' (with a 'vocabulary') " + "and 'model'" + ) from None + if not isinstance(vocabulary, list) or not all( + isinstance(symbol, str) for symbol in vocabulary + ): + raise ValueError("the feature vocabulary must be a list of strings") + if len(set(vocabulary)) != len(vocabulary): + raise ValueError("the feature vocabulary repeats a symbol") + space = FeatureSpace.from_dict(space_data) + model = MLP.from_dict(model_data) + if model.input_size != space.size: + raise ValueError( + f"the network takes {model.input_size} inputs but the feature " + f"space produces {space.size}; the model and its vocabulary " + "do not belong together" + ) + scale = data.get("scale", 1.0) + if isinstance(scale, bool) or not isinstance(scale, (int, float)): + raise ValueError(f"scale must be a number, got {scale!r}") + scale = float(scale) + if not math.isfinite(scale) or scale <= 0.0: + raise ValueError(f"scale must be finite and positive, got {scale!r}") return cls( - FeatureSpace.from_dict(data["space"]), - MLP.from_dict(data["model"]), - float(data.get("scale", 1.0)), + space, + model, + scale, dict(data.get("metrics", {})), dict(data.get("config", {})), ) @@ -91,6 +131,11 @@ class LearnedHeuristic(Heuristic): def __init__(self, task, bundle: HeuristicBundle): super().__init__(task) + if bundle.model.input_size != bundle.space.size: + raise ValueError( + f"the network takes {bundle.model.input_size} inputs but the " + f"feature space produces {bundle.space.size}" + ) self.bundle = bundle self.bound = bundle.space.bind(task) self.scale = bundle.scale diff --git a/jupyddl/learn/model.py b/jupyddl/learn/model.py index 5472cc5..7d5e7e0 100644 --- a/jupyddl/learn/model.py +++ b/jupyddl/learn/model.py @@ -20,6 +20,8 @@ import json import math +import numbers +import operator import random from typing import Optional @@ -53,11 +55,9 @@ class MLP: """ def __init__(self, sizes, seed: int = 0, weights=None, biases=None): - self.sizes = tuple(sizes) - if len(self.sizes) < 2: - raise ValueError("an MLP needs at least an input and an output size") - if self.sizes[-1] != 1: - raise ValueError("a heuristic model has a scalar output") + self.sizes = _check_sizes(sizes) + if (weights is None) != (biases is None): + raise ValueError("an MLP takes both weights and biases, or neither") if weights is None: rng = random.Random(seed) self.weights = [ @@ -69,8 +69,7 @@ def __init__(self, sizes, seed: int = 0, weights=None, biases=None): ] self.biases = [[0.0] * fan_out for fan_out in self.sizes[1:]] else: - self.weights = [[list(row) for row in layer] for layer in weights] - self.biases = [list(b) for b in biases] + self.weights, self.biases = _checked_parameters(self.sizes, weights, biases) self._np_cache: Optional[tuple] = None # -- shape helpers ----------------------------------------------------- @@ -95,7 +94,17 @@ def get_flat(self) -> list: return flat def set_flat(self, flat) -> None: - """Inverse of :meth:`get_flat`.""" + """Inverse of :meth:`get_flat`. + + The length is checked before anything is written. Writing first and + counting afterwards left a wrong-length vector half-applied — rows + truncated, the parameter count in the error already wrong — on a model + the caller still held. + """ + flat = list(flat) + expected = self.num_parameters + if len(flat) != expected: + raise ValueError(f"expected {expected} parameters, got {len(flat)}") index = 0 for layer in self.weights: for row in layer: @@ -106,10 +115,6 @@ def set_flat(self, flat) -> None: width = len(bias) bias[:] = flat[index : index + width] index += width - if index != len(flat): - raise ValueError( - f"expected {self.num_parameters} parameters, got {len(flat)}" - ) self._np_cache = None # -- inference --------------------------------------------------------- @@ -273,7 +278,12 @@ def to_dict(self) -> dict: @classmethod def from_dict(cls, data: dict) -> "MLP": - return cls(data["sizes"], weights=data["weights"], biases=data["biases"]) + """Rebuild a model, refusing one whose parameters do not fit its shape.""" + try: + sizes, weights, biases = data["sizes"], data["weights"], data["biases"] + except (KeyError, TypeError): + raise ValueError("a model needs 'sizes', 'weights' and 'biases'") from None + return cls(sizes, weights=weights, biases=biases) def copy(self) -> "MLP": return MLP(self.sizes, weights=self.weights, biases=self.biases) @@ -332,6 +342,72 @@ def step(self, grad_w, grad_b) -> None: self.model._np_cache = None +def _check_sizes(sizes) -> tuple: + try: + sizes = tuple(operator.index(size) for size in sizes) + except TypeError: + raise ValueError( + f"layer sizes must be a list of integers, got {sizes!r}" + ) from None + if len(sizes) < 2: + raise ValueError("an MLP needs at least an input and an output size") + if any(size < 1 for size in sizes): + raise ValueError(f"layer sizes must be positive, got {list(sizes)}") + if sizes[-1] != 1: + raise ValueError("a heuristic model has a scalar output") + return sizes + + +def _checked_parameters(sizes, weights, biases): + """Copy ``weights`` and ``biases`` as floats, refusing any that do not fit. + + A model read from disk is the case this exists for. Nothing downstream + checks shapes: the pure-Python forward pass ``zip``\\ s rows against inputs + and silently truncates whichever is longer, while the NumPy path raises — + so a model with the wrong input width, a ragged row or a ``sizes`` field + that does not describe its own weights gave a heuristic whose value + depended on whether NumPy was installed. A non-finite weight turns every + prediction into ``nan``, which the planners' priority queues then order + arbitrarily. + """ + layers = len(sizes) - 1 + try: + weights = [[list(row) for row in layer] for layer in weights] + biases = [list(bias) for bias in biases] + except TypeError: + raise ValueError("weights and biases must be nested lists of numbers") from None + if len(weights) != layers or len(biases) != layers: + raise ValueError( + f"sizes {list(sizes)} describe {layers} layers, but there are " + f"{len(weights)} weight and {len(biases)} bias layers" + ) + for index, (fan_in, fan_out) in enumerate(zip(sizes, sizes[1:])): + layer = weights[index] + if len(layer) != fan_out or any(len(row) != fan_in for row in layer): + shape = sorted({len(row) for row in layer}) + raise ValueError( + f"layer {index} must be {fan_out} rows of {fan_in} weights to match " + f"sizes {list(sizes)}; it has {len(layer)} rows of {shape}" + ) + if len(biases[index]) != fan_out: + raise ValueError( + f"layer {index} must have {fan_out} biases to match sizes " + f"{list(sizes)}; it has {len(biases[index])}" + ) + weights = [[[_finite(v) for v in row] for row in layer] for layer in weights] + biases = [[_finite(v) for v in bias] for bias in biases] + return weights, biases + + +def _finite(value) -> float: + if isinstance(value, bool) or not isinstance(value, numbers.Real): + raise ValueError(f"a parameter must be a number, got {value!r}") + value = float(value) + if not math.isfinite(value): + raise ValueError(f"a parameter must be finite, got {value!r}") + return value + + def _softplus(x: float) -> float: # log(1 + e^x), written so a large x does not overflow. if x > 30.0: diff --git a/jupyddl/learn/pipeline.py b/jupyddl/learn/pipeline.py index 0831c59..93ae3b9 100644 --- a/jupyddl/learn/pipeline.py +++ b/jupyddl/learn/pipeline.py @@ -127,18 +127,28 @@ def learn_heuristic( started = time.perf_counter() log = [] + # Materialise every size argument before anything reads it. Each is read + # more than once below, and a generator passed in would be spent by the + # first read: the ladder was recorded as ``[]`` and ``max()`` of it raised. + sizes = list(sizes) + if not sizes: + raise ValueError("learn_heuristic needs at least one training size") + if bootstrap_sizes is not None: + bootstrap_sizes = list(bootstrap_sizes) + if cem_sizes is not None: + cem_sizes = list(cem_sizes) + def note(message): log.append(message) if verbose: # pragma: no cover - operator convenience print(message) tasks = tasks_from_generator(kind, sizes, seed=seed, seeds_per_size=seeds_per_size) - note(f"ladder: {len(tasks)} instances of {kind}, sizes {list(sizes)}") + note(f"ladder: {len(tasks)} instances of {kind}, sizes {sizes}") # The vocabulary must span every task the model will ever see, including # the larger ones it is only evaluated on: a symbol first encountered at # evaluation time would otherwise have no slot. - sizes = list(sizes) if cem_iterations and cem_sizes is None: # A rung above the training ladder: far enough that search has room to # be improved, near enough that it can still be searched cheaply. @@ -268,29 +278,85 @@ def evaluate_transfer( "evaluated": result.stats.evaluated, "seconds": time.perf_counter() - started, "truncated": result.truncated, + "budget": max_expansions, } ) return rows -def summarise_transfer(rows) -> dict: - """Per-heuristic coverage, mean expansions and mean time over solved runs.""" +def summarise_transfer(rows, failure_penalty: float = 2.0) -> dict: + """Per-heuristic coverage, and means that a low coverage cannot flatter. + + This used to average each heuristic over the runs *it* solved, which + rewards giving up: a heuristic that solves only the easiest instance + reports that instance's cost as its mean, and so looks many times better + than one that also solved the hard ones. Now: + + * ``coverage`` is the fraction solved, and belongs next to every mean. + * ``mean_expanded`` and ``mean_seconds`` are over **every** instance. An + unsolved run is charged ``failure_penalty`` times the larger of the + expansion budget it ran under (``row["budget"]``, when recorded) and the + most any configuration spent on that instance — so failing is never + cheaper than solving, by the same rule as + :func:`jupyddl.learn.rl.search_cost`. Seconds are charged the same way + against the slowest run on the instance. + * ``mean_cost`` is over the ``common`` instances that every configuration + with any coverage solved, since a failure has no plan cost to charge. + It is ``None`` when there are none. + + ``solved``, ``total``, ``expanded`` and ``seconds`` are the raw sums the + means are built from (failures charged as above). + """ + rows = list(rows) + instances: dict = {} + for row in rows: + instances.setdefault(row["instance"], []).append(row) + summary: dict = {} for row in rows: - entry = summary.setdefault( + summary.setdefault( row["heuristic"], - {"solved": 0, "total": 0, "expanded": 0, "seconds": 0.0, "cost": 0}, + {"solved": 0, "total": 0, "expanded": 0.0, "seconds": 0.0}, + ) + for runs in instances.values(): + most_expanded = max( + [run["expanded"] for run in runs] + [run.get("budget") or 0 for run in runs] ) - entry["total"] += 1 - if row["solved"]: - entry["solved"] += 1 - entry["expanded"] += row["expanded"] - entry["seconds"] += row["seconds"] - entry["cost"] += row["cost"] or 0 - for entry in summary.values(): - solved = entry["solved"] or 1 - entry["mean_expanded"] = entry["expanded"] / solved - entry["mean_seconds"] = entry["seconds"] / solved - entry["mean_cost"] = entry["cost"] / solved - entry["coverage"] = entry["solved"] / (entry["total"] or 1) + slowest = max(run["seconds"] for run in runs) + for run in runs: + entry = summary[run["heuristic"]] + entry["total"] += 1 + if run["solved"]: + entry["solved"] += 1 + entry["expanded"] += run["expanded"] + entry["seconds"] += run["seconds"] + else: + entry["expanded"] += failure_penalty * most_expanded + entry["seconds"] += failure_penalty * slowest + + # Plan cost only compares on instances everyone with any coverage solved. + # A configuration that solved nothing has no cost to compare and is left + # out of the intersection rather than emptying it. + contenders = {name for name, entry in summary.items() if entry["solved"]} + common = [ + name + for name, runs in instances.items() + if all( + any(run["solved"] for run in runs if run["heuristic"] == heuristic) + for heuristic in contenders + ) + ] + for name, entry in summary.items(): + total = entry["total"] or 1 + entry["coverage"] = entry["solved"] / total + entry["mean_expanded"] = entry["expanded"] / total + entry["mean_seconds"] = entry["seconds"] / total + costs = [ + run["cost"] or 0 + for instance in common + for run in instances[instance] + if run["heuristic"] == name and run["solved"] + ] + entry["common"] = len(common) + entry["mean_cost"] = sum(costs) / len(costs) if costs else None return summary diff --git a/jupyddl/learn/train.py b/jupyddl/learn/train.py index aa04b7b..e6e1ce6 100644 --- a/jupyddl/learn/train.py +++ b/jupyddl/learn/train.py @@ -28,12 +28,15 @@ from dataclasses import dataclass, field from typing import Optional -from .dataset import Corpus +from .dataset import Corpus, instance_family from .heuristic import HeuristicBundle from .model import MLP, Adam __all__ = ["TrainConfig", "TrainReport", "train", "evaluate"] +#: Relative gap below which two successor scores count as tied. +_TIE = 1e-9 + @dataclass class TrainConfig: @@ -84,7 +87,15 @@ class TrainReport: def train(corpus: Corpus, config: Optional[TrainConfig] = None) -> tuple: - """Fit a heuristic to ``corpus``. Returns ``(bundle, report)``.""" + """Fit a heuristic to ``corpus``. Returns ``(bundle, report)``. + + The checkpoint kept is the one with the best ranking accuracy on the + instances :meth:`~jupyddl.learn.dataset.Corpus.split` held out, and + ``report.metrics`` reports it there: ``top1``, ``in_top2`` and ``groups`` + are held-out figures, ``train_top1`` and friends the training ones. When + no held-out instance yielded a ranking group, both selection and ``top1`` + fall back to the training groups and ``ranking_held_out`` is ``False``. + """ config = config or TrainConfig() if not corpus.samples: raise ValueError("cannot train on an empty corpus") @@ -93,6 +104,22 @@ def train(corpus: Corpus, config: Optional[TrainConfig] = None) -> tuple: train_set, val_set = corpus.split(config.validation, seed=config.seed) if not train_set.samples: # pragma: no cover - guarded by split() raise ValueError("the training split came out empty") + if config.rank_weight >= 1.0 and not train_set.groups: + # Every gradient would be zero: the loop would run, select epoch 1 and + # hand back the random initialisation as a trained model. + raise ValueError( + "a pure ranking objective (rank_weight=1.0) needs ranking groups, " + "and the training split has none; build the corpus with " + "ranking=True or give the regression term some weight" + ) + # Checkpoints are selected on ranking accuracy over the held-out instances. + # Only when none of them yielded a group does selection fall back to the + # training groups, and the report then says the figure is not held out — + # as it does for a one-instance corpus, whose split is by sample. + trained_on = {instance_family(s.instance) for s in train_set.samples} + held_out = {instance_family(s.instance) for s in val_set.samples} - trained_on + ranking_groups = val_set.groups or train_set.groups + ranking_held_out = bool(val_set.groups) and bool(held_out) # Normalise targets so the network learns a shape rather than a magnitude. targets = [s.target for s in train_set.samples] @@ -129,7 +156,12 @@ def train(corpus: Corpus, config: Optional[TrainConfig] = None) -> tuple: metrics = evaluate(model, scored, scale) # Selection is on ranking accuracy where there is any to measure, since # that is the quantity search consumes; MAE only breaks ties. - rank_metrics = evaluate_ranking(model, train_set.groups, scale) + rank_metrics = evaluate_ranking(model, ranking_groups, scale) + train_rank = ( + rank_metrics + if ranking_groups is train_set.groups + else evaluate_ranking(model, train_set.groups, scale) + ) score = -(rank_metrics.get("top1", 0.0)) + 1e-3 * metrics["mae"] report.history.append( { @@ -137,7 +169,8 @@ def train(corpus: Corpus, config: Optional[TrainConfig] = None) -> tuple: "loss": epoch_loss / max(1, steps), "val_mae": metrics["mae"], "val_rmse": metrics["rmse"], - "train_top1": rank_metrics.get("top1", 0.0), + "val_top1": rank_metrics.get("top1", 0.0), + "train_top1": train_rank.get("top1", 0.0), } ) if config.verbose: # pragma: no cover - operator convenience @@ -164,8 +197,18 @@ def train(corpus: Corpus, config: Optional[TrainConfig] = None) -> tuple: final.update( {"train_" + k: v for k, v in evaluate(model, train_set, scale).items()} ) - final.update(evaluate_ranking(model, corpus.groups, scale)) - final["held_out_instances"] = len({s.instance for s in val_set.samples}) + # ``top1`` is measured on the held-out instances' groups, the same ones it + # was selected on. It used to be measured over every group in the corpus, + # training ones included, while the command line printed it as held out. + final.update(evaluate_ranking(model, ranking_groups, scale)) + final.update( + { + "train_" + k: v + for k, v in evaluate_ranking(model, train_set.groups, scale).items() + } + ) + final["ranking_held_out"] = ranking_held_out + final["held_out_instances"] = len(held_out) report.metrics = final bundle = HeuristicBundle( @@ -269,9 +312,20 @@ def evaluate_ranking(model: MLP, groups, scale: float) -> dict: """How often the model prefers the successor the plan actually took. This tracks search performance far better than RMSE does. ``top1`` is the - fraction of decision points where the plan's successor scores strictly - lowest; ``in_top2`` allows one better-looking sibling, which a search with - any backtracking will usually survive. + fraction of decision points where the plan's successor comes first; + ``in_top2`` allows one better-looking sibling, which a search with any + backtracking will usually survive. + + **Ties earn partial credit**: a successor level with ``k`` siblings and + beaten by none scores ``1/(k+1)`` towards ``top1`` — its chance of coming + first when the tie is broken at random — and ``in_top2`` is credited the + same way. Both are therefore expected accuracies under random + tie-breaking. Counting a tie as a hit, as this once did, scored a model + that outputs a constant at a perfect 1.0, and checkpoint selection, which + reads this number, then preferred a collapsed epoch. Under partial credit a + constant model scores exactly what guessing would. Scores within a relative + ``1e-9`` of each other count as tied, so the answer does not depend on + whether NumPy summed them in a different order. """ groups = list(groups or ()) if not groups: @@ -284,16 +338,18 @@ def evaluate_ranking(model: MLP, groups, scale: float) -> dict: flat.extend(group.others) spans.append((start, len(flat))) outputs, _ = model.forward_batch([f for f, _ in flat]) - top1 = 0 - top2 = 0 + top1 = 0.0 + top2 = 0.0 for start, end in spans: scores = [flat[i][1] / scale + outputs[i] for i in range(start, end)] chosen = scores[0] - better = sum(1 for s in scores[1:] if s < chosen) - if better == 0: - top1 += 1 - if better <= 1: - top2 += 1 + tolerance = _TIE * max(1.0, abs(chosen)) + better = sum(1 for s in scores[1:] if s < chosen - tolerance) + tied = sum(1 for s in scores[1:] if abs(s - chosen) <= tolerance) + # The chosen successor's rank is uniform over better+1 .. better+tied+1. + slots = tied + 1 + top1 += max(0, min(1 - better, slots)) / slots + top2 += max(0, min(2 - better, slots)) / slots return { "top1": top1 / len(spans), "in_top2": top2 / len(spans), diff --git a/jupyddl/viz/plots.py b/jupyddl/viz/plots.py index 4922fbb..257c950 100644 --- a/jupyddl/viz/plots.py +++ b/jupyddl/viz/plots.py @@ -692,10 +692,12 @@ def plot_benchmark_dashboard( if not rows: raise ValueError("no benchmark rows to plot") pal = palette(dark) + from ..benchmark import row_label + configs: list = [] instances: list = [] for row in rows: - key = f"{row.planner}/{row.heuristic}" if row.heuristic else row.planner + key = row_label(row) if key not in configs: configs.append(key) if row.instance not in instances: @@ -703,8 +705,7 @@ def plot_benchmark_dashboard( lookup = {} for row in rows: - key = f"{row.planner}/{row.heuristic}" if row.heuristic else row.planner - lookup[(row.instance, key)] = row + lookup[(row.instance, row_label(row))] = row colors = [series_color(i, dark) for i in range(len(configs))] positions = list(range(len(configs))) diff --git a/tests/test_benchmark.py b/tests/test_benchmark.py index 9d34cb8..94a2cd9 100644 --- a/tests/test_benchmark.py +++ b/tests/test_benchmark.py @@ -50,3 +50,62 @@ def test_summarize_coverage(examples_available): rows = run_benchmark(instances, [("bfs", None)]) summary = summarize(rows) assert summary["bfs"]["coverage"] == 1 + + +def _demos(*names): + from conftest import DEMOS + + return [i for i in discover_instances(DEMOS) if i.name in names] + + +def test_planner_arguments_keep_configurations_apart(): + """Keyed on ``planner/heuristic`` alone, ``wastar`` at weights 1 and 5 + merged into one entry reporting four instances for a two-instance set.""" + instances = _demos("gripper", "hanoi") + configs = [ + ("wastar", "hff", {"weight": 1.0}), + ("wastar", "hff", {"weight": 5.0}), + ] + rows = run_benchmark(instances, configs, max_expansions=20000) + assert {r.config for r in rows} == { + "wastar/hff(weight=1.0)", + "wastar/hff(weight=5.0)", + } + summary = summarize(rows) + assert set(summary) == {"wastar/hff(weight=1.0)", "wastar/hff(weight=5.0)"} + assert all(agg["instances"] == len(instances) for agg in summary.values()) + + +def test_the_recorded_heuristic_is_the_one_that_ran(): + """``("gbfs", None)`` runs ``hff`` and used to be recorded as ``""``.""" + rows = run_benchmark( + _demos("hanoi"), + [("gbfs", None), ("astar", None), ("gbfs", "none"), ("bfs", None)], + max_expansions=20000, + ) + assert [(r.planner, r.heuristic) for r in rows] == [ + ("gbfs", "hff"), + ("astar", "lmcut"), + ("gbfs", "blind"), + ("bfs", ""), + ] + assert all(r.solved for r in rows) + + +def test_a_repeated_configuration_is_not_merged(): + rows = run_benchmark(_demos("hanoi"), [("gbfs", "hff"), ("gbfs", None)]) + assert [r.config for r in rows] == ["gbfs/hff", "gbfs/hff#2"] + assert all(agg["instances"] == 1 for agg in summarize(rows).values()) + + +def test_a_build_failure_is_labelled_like_a_run(tmp_path): + from jupyddl.benchmark import Instance + + broken = tmp_path / "broken.pddl" + broken.write_text("(define (domain", encoding="utf-8") + rows = run_benchmark( + [Instance("broken", str(broken), str(broken))], + [("wastar", None, {"weight": 3})], + ) + assert rows[0].error + assert (rows[0].heuristic, rows[0].config) == ("hff", "wastar/hff(weight=3)") diff --git a/tests/test_generator.py b/tests/test_generator.py index d617daf..9d8c78f 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -8,6 +8,7 @@ from __future__ import annotations import os +import re import pytest @@ -53,11 +54,80 @@ def test_same_seed_gives_identical_bytes(kind): assert first == second +def _content(text: str) -> str: + """Problem text without comments or the problem's name, both of which + carry the seed whether or not anything else does.""" + text = re.sub(r";;[^\n]*", "", text) + return re.sub(r"\(problem [^\s)]+", "(problem _", text) + + @pytest.mark.parametrize("kind", NAMES) def test_different_seeds_usually_differ(kind): - """Seeds must actually vary the instance, or a ladder is one problem twice.""" - varied = {generate(kind, size=6, seed=seed)[1] for seed in range(6)} + """Seeds must actually vary the instance, or a ladder is one problem twice. + + Compared without the name and comments: gripper and workshop used to put + the seed there and nowhere else, which passed a byte comparison while + twenty seeds gave one instance — so a split by seed held out a copy of a + training instance, and the RL stage's tuning and validation sets were the + same problems. + """ + varied = {_content(generate(kind, size=6, seed=seed)[1]) for seed in range(6)} assert len(varied) > 1, f"{kind} ignores its seed" + smaller = {_content(generate(kind, size=3, seed=seed)[1]) for seed in range(20)} + assert len(smaller) >= 4, f"{kind} has only {len(smaller)} instances at size 3" + + +@pytest.mark.parametrize("kind", NAMES) +def test_no_instance_is_solved_before_it_starts(kind): + """A goal that holds in the initial state teaches a learner nothing and + flatters every planner. Blocksworld at two blocks did this for 75 of the + first 200 seeds.""" + for size in (2, 3, 4): + for seed in range(40): + task = build(kind, size=size, seed=seed) + assert not task.goal_reached(task.initial_state()), (kind, size, seed) + + +def test_random_strips_never_samples_a_goal_that_already_holds(): + """The goal is drawn from the end of the planted chain, and could land + entirely on facts the initial state already had.""" + for size in range(9, 13): + for seed in range(60): + task = build("random-strips", size=size, seed=seed) + assert not task.goal_reached(task.initial_state()), (size, seed) + + +def test_random_strips_survives_a_chain_that_undoes_itself(): + """Seed 11 at size 9 with four actions plants a chain whose end state is a + subset of its start, so every fact a goal could be drawn from already + holds. One more planted step gives the goal something to ask for.""" + task = build("random-strips", size=9, seed=11, actions=4) + assert not task.goal_reached(task.initial_state()) + result = solve_task(task, "astar", "blind") + assert result.solved and validate_plan(task, result.plan) + + +def test_only_previously_trivial_instances_changed(): + """The re-draws above happen only where an instance was trivial, so every + other seed still emits the bytes it always did. ``demos/blocksworld12`` is + published as exactly this generator's output.""" + demo = os.path.join(os.path.dirname(__file__), "..", "demos", "blocksworld12") + with open(os.path.join(demo, "problem.pddl"), encoding="utf-8") as handle: + assert generate("blocksworld", size=12, seed=3)[1] == handle.read() + + +def test_gripper_and_workshop_seeds_change_where_things_start(): + gripper = build("gripper", size=4, seed=5) + assert len(gripper.goals) == 4 # every ball still has to move + + def initial(kind, size, seed): + return generate(kind, size=size, seed=seed)[1].split("(:goal")[0] + + starts = {initial("gripper", 4, seed).count(" rooma)") for seed in range(20)} + assert len(starts) > 1, "every seed puts the balls in the same rooms" + + stages = {initial("workshop", 3, seed).count("(cut ") for seed in range(20)} + assert len(stages) > 1, "every seed starts the parts at the same stage" @pytest.mark.parametrize("kind", ["gripper", "blocksworld", "logistics"]) diff --git a/tests/test_learn.py b/tests/test_learn.py index 1b6e121..0dd0c0a 100644 --- a/tests/test_learn.py +++ b/tests/test_learn.py @@ -72,6 +72,10 @@ def corpus(ladder, space): ("(at truck1 loc2)", "at"), ("move(a,b)#2", "move"), ("__closed", "__closed"), + # Complement facts, which grounding adds for negative preconditions. + ("(not (on b1 b2))", "not-on"), + ("(not (handempty))", "not-handempty"), + ("( not ( sampled wp1 ) )", "not-sampled"), ], ) def test_predicate_symbols_are_extracted_from_both_spellings(fact, symbol): @@ -83,6 +87,32 @@ def test_predicate_symbols_are_extracted_from_both_spellings(fact, symbol): assert predicate_of(fact) == symbol +def test_each_negated_predicate_gets_its_own_slot(): + """``(not (p o1))`` and ``(not (q o1))`` used to share one slot, ``not``, + so a feature counted the negations of every predicate in the domain at + once and could not say which ones held.""" + from jupyddl.grounding import ground + from jupyddl.parser import parse + + domain = """(define (domain d) (:requirements :strips :negative-preconditions) + (:predicates (p ?x) (q ?x) (done)) + (:action a :parameters (?x) :precondition (not (p ?x)) :effect (p ?x)) + (:action b :parameters (?x) :precondition (not (q ?x)) :effect (q ?x)) + (:action c :parameters () :precondition (and (p o1) (q o2)) + :effect (done)))""" + problem = """(define (problem pr) (:domain d) (:objects o1 o2) + (:init (p o1)) (:goal (done)))""" + task = ground(parse(domain), parse(problem)) + space = FeatureSpace.from_task(task) + assert "not" not in space.vocabulary + assert {"not-p", "not-q"} <= set(space.vocabulary) + + values = dict(zip(space.names(), space.bind(task)(task.initial_state()))) + # (p o1) holds, so half of p's complements do; none of q's atoms hold. + assert values["true:not-p"] == pytest.approx(0.5) + assert values["true:not-q"] == pytest.approx(1.0) + + def test_vocabulary_is_predicates_not_atoms(space): assert set(space.vocabulary) == {"on", "ontable", "clear", "handempty", "holding"} @@ -211,6 +241,22 @@ def test_set_flat_rejects_the_wrong_length(): model.set_flat([0.0]) +@pytest.mark.parametrize("delta", [-10, -1, 1]) +def test_a_rejected_set_flat_leaves_the_model_untouched(delta): + """It used to write row by row and count afterwards, so a short vector + truncated the rows of a model the caller still held — and the error then + reported the already-damaged parameter count.""" + model = MLP([3, 2, 1], seed=0) + before = model.get_flat() + prediction = model([1.0, -1.0, 0.5]) + wrong = before + [5.0] if delta > 0 else before[:delta] + with pytest.raises(ValueError, match=f"expected 11 parameters, got {len(wrong)}"): + model.set_flat(wrong) + assert model.get_flat() == before + assert model.num_parameters == 11 + assert model([1.0, -1.0, 0.5]) == prediction + + def test_adam_reduces_a_loss_it_can_reach(): rng = random.Random(1) model = MLP([3, 4, 1], seed=0) @@ -344,6 +390,157 @@ def test_training_refuses_an_empty_corpus(space): train(Corpus(space), TrainConfig(epochs=1)) +def test_pure_ranking_without_groups_is_refused(ladder, space): + """Every gradient is zero, so this used to return the random + initialisation, selected at epoch 1, as a trained model.""" + flat = solved_corpus(ladder, space=space, ranking=False, time_limit=20.0) + assert flat.samples and not flat.groups + with pytest.raises(ValueError, match="ranking groups"): + train(flat, TrainConfig(epochs=3, rank_weight=1.0, seed=0)) + # With any weight on regression there is still something to learn. + bundle, _ = train(flat, TrainConfig(epochs=3, rank_weight=0.5, seed=0)) + assert bundle.model.get_flat() != MLP(bundle.model.sizes, seed=0).get_flat() + + +# -------------------------------------------------------------------------- +# ranking accuracy: ties, and what "held out" means +# -------------------------------------------------------------------------- +@pytest.fixture(params=["python", "numpy"]) +def forward_path(request, monkeypatch): + """Run a test once on the reference path and once on the NumPy one.""" + from jupyddl.learn import model as model_module + + if request.param == "numpy": + if not numpy_available(): + pytest.skip("numpy is not installed") + else: + monkeypatch.setattr(model_module, "_np", None) + return request.param + + +def _constant_model(size): + model = MLP([size, 4, 1], seed=0) + model.set_flat([0.0] * model.num_parameters) + return model + + +def test_a_constant_model_ranks_at_chance_not_perfectly(corpus, forward_path): + """Ties used to count as hits, so a model that outputs one number for + every state scored top-1 = 1.0 — and checkpoint selection, which reads + that score, preferred a collapsed epoch to a trained one.""" + # Blocksworld actions all cost 1, so a constant output leaves every + # sibling level with the successor the plan took. + assert all(c == g.chosen[1] for g in corpus.groups for _, c in g.others) + model = _constant_model(corpus.space.size) + metrics = evaluate_ranking(model, corpus.groups, 1.0) + chance = sum(1 / (len(g.others) + 1) for g in corpus.groups) / len(corpus.groups) + assert metrics["top1"] == pytest.approx(chance) + assert metrics["top1"] < 0.6 + assert metrics["in_top2"] >= metrics["top1"] + + +def test_ties_earn_partial_credit(forward_path): + from jupyddl.learn.dataset import RankingGroup + + model = _constant_model(2) # every state scores the same; step costs decide + a, b, c = [1.0, 0.0], [0.0, 1.0], [1.0, 1.0] + groups = [ + RankingGroup((a, 1.0), [(b, 1.0)]), # one tie: half a hit + RankingGroup((a, 1.0), [(b, 1.0), (c, 1.0)]), # two ties: a third + RankingGroup((a, 1.0), [(b, 2.0)]), # strictly best: a hit + RankingGroup((a, 2.0), [(b, 1.0), (c, 2.0)]), # one better, one tie + RankingGroup((a, 3.0), [(b, 1.0), (c, 2.0)]), # third: nothing + ] + metrics = evaluate_ranking(model, groups, 1.0) + assert metrics["top1"] == pytest.approx((1 / 2 + 1 / 3 + 1 + 0 + 0) / 5) + assert metrics["in_top2"] == pytest.approx((1 + 2 / 3 + 1 + 1 / 2 + 0) / 5) + + +def test_both_forward_paths_rank_identically(corpus): + """A near-tie must not be a win on one path and a loss on the other.""" + if not numpy_available(): + pytest.skip("numpy is not installed") + from jupyddl.learn import model as model_module + + bundle, _ = train(corpus, TrainConfig(epochs=10, seed=0)) + fast = evaluate_ranking(bundle.model, corpus.groups, bundle.scale) + saved = model_module._np + model_module._np = None + try: + slow = evaluate_ranking(bundle.model, corpus.groups, bundle.scale) + finally: + model_module._np = saved + assert fast == slow + + +def test_no_sibling_duplicates_the_chosen_successor(): + """In gripper, picking a ball up with either hand gives two different + states the features cannot tell apart. Kept as a sibling, that pair is a + tie no model can break.""" + tasks = tasks_from_generator("gripper", [3, 4], seed=0) + space = FeatureSpace.from_tasks(task for _, task in tasks) + gripper = solved_corpus(tasks, space=space, time_limit=20.0) + assert gripper.groups + for group in gripper.groups: + assert all(other != group.chosen for other in group.others) + + +def test_split_holds_out_ranking_groups_with_their_instances(corpus): + """The held-out side used to get no groups at all, so the only top-1 + there was to report was measured on training instances.""" + from jupyddl.learn.dataset import instance_family + + train_set, val_set = corpus.split(validation=0.34, seed=0) + assert val_set.groups, "held-out instances that branch must keep their groups" + held = {instance_family(s.instance) for s in val_set.samples} + assert {instance_family(g.instance) for g in val_set.groups} <= held + assert not {instance_family(g.instance) for g in train_set.groups} & held + assert len(train_set.groups) + len(val_set.groups) == len(corpus.groups) + + +def test_reported_top1_is_measured_on_held_out_instances(corpus): + config = TrainConfig(epochs=15, seed=0, validation=0.34) + bundle, report = train(corpus, config) + _, val_set = corpus.split(config.validation, seed=config.seed) + held_out = evaluate_ranking(bundle.model, val_set.groups, bundle.scale) + assert bundle.metrics["ranking_held_out"] is True + assert bundle.metrics["top1"] == pytest.approx(held_out["top1"]) + assert bundle.metrics["groups"] == len(val_set.groups) < len(corpus.groups) + assert "train_top1" in bundle.metrics + assert "val_top1" in report.history[0] + + +def test_relabelled_states_stay_with_their_instance(space): + """DAgger and bootstrapping file states of ``X`` under ``X#dagger1``, + ``X#boot2``: splitting on the full name let ``X`` train while its own + relabelled states validated.""" + from jupyddl.learn.dataset import RankingGroup, Sample, instance_family + + names = [] + for base in ("a", "b", "c", "d", "e", "f"): + names += [base, f"{base}#dagger1", f"{base}#dagger2", f"{base}#boot1"] + vector = [0.0] * space.size + mixed = Corpus( + space, + [Sample(vector, 1.0, True, name) for name in names], + [RankingGroup((vector, 1.0), [(vector, 2.0)], name) for name in names], + ) + for seed in range(20): + train_set, val_set = mixed.split(validation=0.34, seed=seed) + trained = {instance_family(s.instance) for s in train_set.samples} + trained |= {instance_family(g.instance) for g in train_set.groups} + validated = {instance_family(s.instance) for s in val_set.samples} + validated |= {instance_family(g.instance) for g in val_set.groups} + assert validated and not trained & validated, seed + + +def test_a_single_instance_is_not_reported_as_held_out(ladder, space): + one = solved_corpus(ladder[:1], space=space, time_limit=20.0) + bundle, _ = train(one, TrainConfig(epochs=3, seed=0)) + assert bundle.metrics["held_out_instances"] == 0 + assert bundle.metrics["ranking_held_out"] is False + + def test_training_is_deterministic_given_a_seed(corpus): a, _ = train(corpus, TrainConfig(epochs=10, seed=3)) b, _ = train(corpus, TrainConfig(epochs=10, seed=3)) @@ -373,6 +570,106 @@ def test_a_future_format_is_refused_rather_than_misread(corpus, tmp_path): HeuristicBundle.load(str(path)) +def _break_width(d): + d["model"] = MLP([6, 3, 1], seed=0).to_dict() + + +def _break_row(d): + d["model"]["weights"][0][0] = d["model"]["weights"][0][0][:2] + + +def _lie_about_sizes(d): + d["model"]["sizes"] = [999, 1] + + +def _lie_about_a_hidden_layer(d): + d["model"]["sizes"] = [8, 4, 1] + + +def _nan_weight(d): + d["model"]["weights"][1][0][0] = float("nan") + + +def _inf_bias(d): + d["model"]["biases"][0][1] = float("inf") + + +def _text_weight(d): + d["model"]["weights"][0][1][2] = "0.5" + + +def _short_bias(d): + d["model"]["biases"][1] = [] + + +def _no_biases(d): + del d["model"]["biases"] + + +def _zero_scale(d): + d["scale"] = 0.0 + + +def _nan_scale(d): + d["scale"] = float("nan") + + +def _repeated_symbol(d): + d["space"]["vocabulary"] = ["on", "on"] + + +@pytest.mark.parametrize( + "corrupt", + [ + _break_width, + _break_row, + _lie_about_sizes, + _lie_about_a_hidden_layer, + _nan_weight, + _inf_bias, + _text_weight, + _short_bias, + _no_biases, + _zero_scale, + _nan_scale, + _repeated_symbol, + ], +) +def test_a_malformed_bundle_is_refused_on_load(corrupt, tmp_path): + """Each of these used to load. The pure-Python forward pass ``zip``\\ s rows + against inputs and silently drops whatever is longer while the NumPy path + raises, so the same file gave a different heuristic depending on whether + NumPy was installed — and a non-finite value made every estimate ``nan``.""" + bundle = HeuristicBundle(FeatureSpace(["clear", "on"]), MLP([8, 3, 1], seed=0)) + data = bundle.to_dict() + HeuristicBundle.from_dict(json.loads(json.dumps(data))) # the control loads + corrupt(data) + path = tmp_path / "broken.heur.json" + path.write_text(json.dumps(data), encoding="utf-8") + with pytest.raises(ValueError): + HeuristicBundle.load(str(path)) + + +def test_a_mismatched_bundle_built_in_code_refuses_to_bind(ladder): + """The same check for a bundle that never went through a file.""" + _, task = ladder[0] + space = FeatureSpace.from_task(task) + bundle = HeuristicBundle(space, MLP([space.size - 1, 3, 1], seed=0)) + with pytest.raises(ValueError, match="inputs"): + bundle.bind(task) + + +def test_mlp_refuses_parameters_that_do_not_fit_its_sizes(): + good = MLP([3, 2, 1], seed=0) + assert MLP.from_dict(good.to_dict()).get_flat() == good.get_flat() + with pytest.raises(ValueError): + MLP([3, 2, 1], weights=good.weights) # biases missing + with pytest.raises(ValueError): + MLP([3, 0, 1]) + with pytest.raises(ValueError): + MLP([3.5, 1]) + + def test_the_heuristic_scores_a_goal_state_zero(corpus, ladder): bundle, _ = train(corpus, TrainConfig(epochs=20, seed=0)) from jupyddl.api import solve_task @@ -639,6 +936,77 @@ def test_transfer_evaluation_reports_time_as_well_as_expansions(corpus, ladder): assert summary["learned"]["coverage"] == 1.0 +def _row(heuristic, instance, solved, expanded, cost=None, seconds=0.01, budget=1000): + return { + "instance": instance, + "heuristic": heuristic, + "solved": solved, + "cost": cost, + "expanded": expanded, + "evaluated": expanded, + "seconds": seconds, + "truncated": not solved, + "budget": budget, + } + + +def test_transfer_summary_does_not_reward_giving_up(): + """``quitter`` solves only the easy instance; ``finisher`` solves both. + Averaged over each one's own solved runs — what this used to do — the + quitter came out 26 times better.""" + rows = [ + _row("quitter", "easy", True, 10, cost=4, seconds=0.01), + _row("quitter", "hard", False, 1000, seconds=0.5), + _row("finisher", "easy", True, 20, cost=6, seconds=0.02), + _row("finisher", "hard", True, 500, cost=30, seconds=0.3), + _row("hopeless", "easy", False, 1000, seconds=0.4), + _row("hopeless", "hard", False, 1000, seconds=0.4), + ] + summary = summarise_transfer(rows) + quitter, finisher = summary["quitter"], summary["finisher"] + assert quitter["coverage"] == 0.5 and finisher["coverage"] == 1.0 + assert quitter["mean_expanded"] > finisher["mean_expanded"] + # A failure is charged twice the budget, as search_cost charges it. + assert quitter["mean_expanded"] == pytest.approx((10 + 2 * 1000) / 2) + assert finisher["mean_expanded"] == pytest.approx((20 + 500) / 2) + assert quitter["mean_seconds"] > finisher["mean_seconds"] + # Plan cost compares only where everyone who solved anything solved: + # the configuration that solved nothing does not empty that set. + assert finisher["common"] == 1 + assert quitter["mean_cost"] == 4 and finisher["mean_cost"] == 6 + assert summary["hopeless"]["mean_cost"] is None + assert summary["hopeless"]["mean_expanded"] == pytest.approx(2000) + + +def test_transfer_rows_record_the_budget(corpus, ladder): + bundle, _ = train(corpus, TrainConfig(epochs=5, seed=0)) + rows = evaluate_transfer(bundle, ladder[:1], baselines=(), max_expansions=3) + assert rows[0]["budget"] == 3 + if not rows[0]["solved"]: + charged = 2 * max(3, rows[0]["expanded"]) + assert summarise_transfer(rows)["learned"]["mean_expanded"] == charged + + +def test_learn_heuristic_accepts_a_one_shot_iterable_of_sizes(): + """``sizes`` is read several times; a generator used to be spent by the + first read, so the ladder was recorded as ``[]`` and the default CEM sizes + (``max`` of it) raised.""" + from jupyddl.learn import learn_heuristic + + bundle = learn_heuristic( + "blocksworld", + sizes=(n for n in (3, 4)), + seeds_per_size=1, + train_config=TrainConfig(epochs=2, seed=0), + rl_config=RLConfig(max_expansions=50, seed=0), + cem_iterations=1, + ) + pipeline = bundle.metrics["pipeline"] + assert pipeline["sizes"] == [3, 4] + assert pipeline["log"][0].endswith("sizes [3, 4]") + assert any("sizes [6, 7, 8, 9]" in line for line in pipeline["log"]) + + def test_a_model_transfers_to_an_instance_far_larger_than_it_trained_on(corpus): """Not a quality claim — a claim that it runs at all, which is the thing a fixed-length one-hot encoding would have made impossible.""" @@ -651,3 +1019,25 @@ def test_a_model_transfers_to_an_instance_far_larger_than_it_trained_on(corpus): result = solve_task(big, "gbfs", heuristic, max_expansions=20000, time_limit=30) if result.solved: assert validate_plan(big, result.plan) + + +def test_the_cli_says_held_out_only_of_what_was(tmp_path, capsys): + """With one instance nothing can be held out by instance; the command line + used to print the training figures under "held out" anyway.""" + from jupyddl.cli import main + + out = tmp_path / "one.heur.json" + args = ["learn", "blocksworld", "--sizes", "3", "--seeds-per-size", "1"] + assert main(args + ["--epochs", "2", "-o", str(out)]) == 0 + printed = capsys.readouterr().out + assert "nothing was held out" in printed + assert "held out MAE" not in printed + assert "(held out)" not in printed + + out = tmp_path / "ladder.heur.json" + args = ["learn", "blocksworld", "--sizes", "3-4", "--seeds-per-size", "3"] + assert main(args + ["--epochs", "2", "--evaluate", "4", "-o", str(out)]) == 0 + printed = capsys.readouterr().out + assert "held out MAE" in printed + assert "(held out)" in printed + assert "an unsolved one charged double" in printed diff --git a/tools/build_web.py b/tools/build_web.py index 9b7bffe..1b1d900 100644 --- a/tools/build_web.py +++ b/tools/build_web.py @@ -216,11 +216,18 @@ def collect_research() -> dict: data = json.load(handle) def rows(summary): + # ``summarise_transfer`` reports no plan cost (None) for a heuristic + # that solved none of the instances the others did; a cache measured + # before that change has 0.0 there instead. return { name: { "expanded": round(entry["mean_expanded"], 1), "seconds": round(entry["mean_seconds"], 4), - "cost": round(entry["mean_cost"], 1), + "cost": ( + None + if entry.get("mean_cost") is None + else round(entry["mean_cost"], 1) + ), "coverage": round(entry["coverage"], 2), } for name, entry in summary.items() diff --git a/web/dist/jupyddl-sources.json b/web/dist/jupyddl-sources.json index b3c316a..796837c 100644 --- a/web/dist/jupyddl-sources.json +++ b/web/dist/jupyddl-sources.json @@ -1 +1 @@ -{"jupyddl/__init__.py": "\"\"\"jupyddl: a pure-Python PDDL planning framework.\n\nQuickstart::\n\n from jupyddl import solve, build_task, trace_search, validate_plan\n\n result = solve(\"domain.pddl\", \"problem.pddl\", search=\"astar\", heuristic=\"lmcut\")\n print(result.solved, result.cost, result.plan_names())\n\n # ...and watch the search itself\n task = build_task(\"domain.pddl\", \"problem.pddl\")\n result, trace = trace_search(task, \"astar\", \"lmcut\")\n trace.save(\"run.json\")\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom .api import build_task, solve, solve_task, trace_search, validate_plan\nfrom .grounding import ground, ground_files\nfrom .heuristics import HEURISTICS, make_heuristic\nfrom .parser import PDDLError, UnsupportedFeatureError, parse\nfrom .search import PLANNERS, SearchResult, make_planner\nfrom .task import Operator, Task\nfrom .trace import (\n MultiObserver,\n SearchEvent,\n SearchObserver,\n SearchTrace,\n TraceRecorder,\n)\n\n__version__ = \"2.4.0\" # x-release-please-version\n\n__all__ = [\n \"solve\",\n \"solve_task\",\n \"build_task\",\n \"trace_search\",\n \"validate_plan\",\n \"ground\",\n \"ground_files\",\n \"make_planner\",\n \"make_heuristic\",\n \"PLANNERS\",\n \"HEURISTICS\",\n \"SearchResult\",\n \"SearchTrace\",\n \"SearchEvent\",\n \"SearchObserver\",\n \"MultiObserver\",\n \"TraceRecorder\",\n \"Task\",\n \"Operator\",\n \"parse\",\n \"PDDLError\",\n \"UnsupportedFeatureError\",\n \"__version__\",\n]\n", "jupyddl/api.py": "\"\"\"High-level convenience API: parse + ground + plan + validate.\"\"\"\n\nfrom __future__ import annotations\n\nfrom .grounding import ground_files\nfrom .heuristics import make_heuristic\nfrom .search import make_planner\nfrom .search.result import SearchResult, make_budget\nfrom .task import Task\nfrom .trace import TraceRecorder\n\n\ndef build_task(domain_path: str, problem_path: str) -> Task:\n \"\"\"Parse and ground a domain/problem pair into a :class:`Task`.\"\"\"\n return ground_files(domain_path, problem_path)\n\n\ndef solve_task(\n task: Task,\n search: str = \"astar\",\n heuristic=None,\n observer=None,\n max_expansions=None,\n time_limit=None,\n **planner_kwargs,\n) -> SearchResult:\n \"\"\"Run ``search`` (optionally with ``heuristic``) on an already-ground task.\n\n Pass ``observer`` (see :mod:`jupyddl.trace`) to record or live-render the\n search as it runs. ``max_expansions`` and ``time_limit`` bound the run; when\n either is hit the planner stops and sets ``result.truncated``, so an empty\n result means \"gave up\", not \"proved unsolvable\".\n\n A planner that needs a heuristic and is not given one gets ``lmcut`` if it\n is optimal (so the default keeps the guarantee) and ``hff`` otherwise.\n ``heuristic=\"none\"`` asks for no guidance at all, which for a planner that\n needs a heuristic means ``blind``.\n \"\"\"\n planner = make_planner(search, **planner_kwargs)\n heur = None\n name = heuristic\n if name == \"none\":\n name = \"blind\" if planner.requires_heuristic else None\n elif not name and planner.requires_heuristic:\n # An optimal planner gets an admissible default: hff would quietly\n # cost it its guarantee.\n name = \"lmcut\" if planner.optimal else \"hff\"\n if name:\n heur = make_heuristic(name, task)\n budget = make_budget(max_expansions, time_limit)\n return planner.search(task, heur, observer=observer, budget=budget)\n\n\ndef solve(\n domain_path: str,\n problem_path: str,\n search: str = \"astar\",\n heuristic=\"lmcut\",\n observer=None,\n max_expansions=None,\n time_limit=None,\n **planner_kwargs,\n) -> SearchResult:\n \"\"\"Parse, ground and solve a PDDL instance in one call.\"\"\"\n task = build_task(domain_path, problem_path)\n return solve_task(\n task,\n search=search,\n heuristic=heuristic,\n observer=observer,\n max_expansions=max_expansions,\n time_limit=time_limit,\n **planner_kwargs,\n )\n\n\ndef trace_search(\n task: Task,\n search: str = \"astar\",\n heuristic=None,\n max_events: int = 20000,\n record_generated: bool = False,\n observer=None,\n max_expansions=None,\n time_limit=None,\n **planner_kwargs,\n):\n \"\"\"Solve ``task`` while recording the search.\n\n Returns ``(result, trace)`` where ``trace`` is a\n :class:`~jupyddl.trace.SearchTrace` ready to plot, save or replay. An extra\n ``observer`` (a live dashboard, say) is notified alongside the recorder.\n \"\"\"\n recorder = TraceRecorder(max_events=max_events, record_generated=record_generated)\n if observer is not None:\n from .trace import MultiObserver\n\n sink = MultiObserver(recorder, observer)\n else:\n sink = recorder\n result = solve_task(\n task,\n search=search,\n heuristic=heuristic,\n observer=sink,\n max_expansions=max_expansions,\n time_limit=time_limit,\n **planner_kwargs,\n )\n return result, recorder.trace\n\n\ndef validate_plan(task: Task, plan) -> bool:\n \"\"\"Return ``True`` iff applying ``plan`` from the initial state reaches the goal.\n\n Replays through the task rather than the raw operators, so derived\n predicates are closed and numeric fluents are carried at every step \u2014\n validating against ``task.init`` directly would see neither.\n \"\"\"\n state = task.initial_state()\n for op in plan or ():\n if not op.applicable(state):\n return False\n state = task.apply(op, state)\n return task.goal_reached(state)\n", "jupyddl/benchmark.py": "\"\"\"Comparative benchmarking of planners/heuristics over PDDL instances.\n\nExample::\n\n from jupyddl.benchmark import discover_instances, run_benchmark, to_csv\n rows = run_benchmark(discover_instances(\"pddl-examples\"),\n [(\"astar\", \"lmcut\"), (\"gbfs\", \"hff\")])\n to_csv(rows, \"results.csv\")\n\"\"\"\n\nfrom __future__ import annotations\n\nimport csv\nimport glob\nimport os\nfrom dataclasses import asdict, dataclass\nfrom typing import Optional\n\nfrom .api import build_task, solve_task, validate_plan\nfrom .parser import PDDLError\n\n\n@dataclass\nclass Instance:\n name: str\n domain: str\n problem: str\n\n\n@dataclass\nclass BenchmarkRow:\n instance: str\n planner: str\n heuristic: str\n solved: bool\n valid: bool\n cost: Optional[int]\n plan_length: Optional[int]\n expanded: int\n generated: int\n evaluated: int\n runtime: float\n error: str = \"\"\n truncated: bool = False\n\n\ndef discover_instances(root: str) -> list:\n \"\"\"Find ``/*/`` folders containing both ``domain.pddl`` and ``problem.pddl``.\"\"\"\n instances = []\n for domain in sorted(glob.glob(os.path.join(root, \"*\", \"domain.pddl\"))):\n folder = os.path.dirname(domain)\n problem = os.path.join(folder, \"problem.pddl\")\n if os.path.exists(problem):\n instances.append(Instance(os.path.basename(folder), domain, problem))\n return instances\n\n\ndef run_benchmark(instances, configs, max_expansions=None, time_limit=None) -> list:\n \"\"\"Run each ``(planner, heuristic[, kwargs])`` config on each instance.\n\n ``configs`` entries are ``(planner_name, heuristic_name_or_None)`` or\n ``(planner_name, heuristic_name_or_None, planner_kwargs)``.\n\n ``max_expansions`` and ``time_limit`` bound every individual run, which is\n what keeps one pathological instance from stalling a whole benchmark. A run\n that stops on its budget is recorded with ``truncated=True`` and does not\n count towards coverage -- \"we stopped looking\" is not \"no plan exists\".\n \"\"\"\n rows: list = []\n for inst in instances:\n try:\n task = build_task(inst.domain, inst.problem)\n except (PDDLError, ValueError) as exc:\n for cfg in configs:\n planner, heuristic = cfg[0], (cfg[1] or \"\")\n rows.append(\n BenchmarkRow(\n inst.name,\n planner,\n heuristic,\n False,\n False,\n None,\n None,\n 0,\n 0,\n 0,\n 0.0,\n f\"{type(exc).__name__}: {exc}\",\n )\n )\n continue\n for cfg in configs:\n planner = cfg[0]\n heuristic = cfg[1]\n kwargs = cfg[2] if len(cfg) > 2 else {}\n try:\n result = solve_task(\n task,\n planner,\n heuristic,\n max_expansions=max_expansions,\n time_limit=time_limit,\n **kwargs,\n )\n valid = bool(result.solved and validate_plan(task, result.plan))\n rows.append(\n BenchmarkRow(\n inst.name,\n planner,\n heuristic or \"\",\n result.solved,\n valid,\n result.cost,\n result.plan_length,\n result.stats.expanded,\n result.stats.generated,\n result.stats.evaluated,\n round(result.stats.runtime, 6),\n \"\",\n result.stats.truncated,\n )\n )\n except Exception as exc: # keep the benchmark going on a single failure\n rows.append(\n BenchmarkRow(\n inst.name,\n planner,\n heuristic or \"\",\n False,\n False,\n None,\n None,\n 0,\n 0,\n 0,\n 0.0,\n f\"{type(exc).__name__}: {exc}\",\n )\n )\n return rows\n\n\ndef to_csv(rows, path: str) -> None:\n fieldnames = (\n list(asdict(rows[0]).keys())\n if rows\n else [f.name for f in BenchmarkRow.__dataclass_fields__.values()]\n )\n with open(path, \"w\", newline=\"\", encoding=\"utf-8\") as handle:\n writer = csv.DictWriter(handle, fieldnames=fieldnames)\n writer.writeheader()\n for row in rows:\n writer.writerow(asdict(row))\n\n\ndef summarize(rows) -> dict:\n \"\"\"Aggregate coverage and totals per ``planner/heuristic`` configuration.\"\"\"\n summary: dict = {}\n for row in rows:\n key = f\"{row.planner}/{row.heuristic}\" if row.heuristic else row.planner\n agg = summary.setdefault(\n key, {\"coverage\": 0, \"expanded\": 0, \"runtime\": 0.0, \"instances\": 0}\n )\n agg[\"instances\"] += 1\n agg[\"coverage\"] += int(row.valid)\n agg[\"expanded\"] += row.expanded\n agg[\"runtime\"] += row.runtime\n return summary\n\n\ndef plot_summary(rows, path: str, metric: str = \"expanded\") -> None:\n \"\"\"Bar chart of a metric per configuration (requires the ``viz`` extra).\"\"\"\n import matplotlib\n\n matplotlib.use(\"Agg\")\n import matplotlib.pyplot as plt\n\n summary = summarize(rows)\n labels = list(summary.keys())\n values = [summary[k][metric] for k in labels]\n fig, ax = plt.subplots(figsize=(max(6, len(labels) * 0.9), 4))\n ax.bar(labels, values, color=\"#4C72B0\")\n ax.set_ylabel(f\"total {metric}\")\n ax.set_title(f\"Planner comparison ({metric})\")\n plt.xticks(rotation=45, ha=\"right\")\n plt.tight_layout()\n fig.savefig(path, dpi=120)\n plt.close(fig)\n", "jupyddl/cli.py": "\"\"\"Command-line interface.\n\n``solve``, ``benchmark``, ``animate``, ``demo``, ``requirements``, ``generate``,\n``learn`` and ``plugins``. Every command that searches accepts\n``--max-expansions`` and ``--time-limit``; when a search stops on one of those\nit says so rather than reporting the instance unsolvable.\n\nExit codes, so a script can tell the outcomes apart:\n\n===== =========================================================\n0 solved, and the plan validates\n1 the search finished and found no plan\n2 bad input: a missing file, a parse error, an unknown name\n3 a budget stopped the search before it found a plan\n4 a plan was found but failed validation (a bug: report it)\n===== =========================================================\n\nSet ``JUPYDDL_DEBUG=1`` to get the full traceback instead of a one-line error.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport os\nimport sys\n\nfrom . import __version__\nfrom .api import build_task, solve_task, trace_search, validate_plan\nfrom .benchmark import (\n discover_instances,\n plot_summary,\n run_benchmark,\n summarize,\n to_csv,\n)\nfrom .heuristics import HEURISTICS, LOADERS\nfrom .parser import PDDLError\nfrom .plugins import describe_plugins, load_plugins\nfrom .search import PLANNERS\n\nEXIT_SOLVED = 0\nEXIT_NO_PLAN = 1\nEXIT_BAD_INPUT = 2\nEXIT_TRUNCATED = 3\nEXIT_INVALID_PLAN = 4\n\nEXIT_CODES = \"\"\"exit codes:\n 0 solved, and the plan validates\n 1 the search finished and found no plan\n 2 bad input: a missing file, a parse error, an unknown name\n 3 a budget stopped the search before it found a plan\n 4 a plan was found but failed validation (a bug: please report it)\"\"\"\n\n\nclass CLIError(Exception):\n \"\"\"A problem with what the user asked for, reported as one line.\"\"\"\n\n\ndef _need_viz(what: str) -> None:\n \"\"\"Fail before searching, not after, when a chart needs matplotlib.\"\"\"\n try:\n import matplotlib # noqa: F401\n except ImportError:\n raise CLIError(\n f\"{what} needs the viz extra: pip install 'jupyddl[viz]'\"\n ) from None\n\n\ndef _add_budget(p, default_time=None) -> None:\n p.add_argument(\n \"--max-expansions\",\n type=int,\n default=None,\n help=\"stop each search after this many node expansions\",\n )\n hint = f\" (default {default_time:g}; 0 for none)\" if default_time else \"\"\n p.add_argument(\n \"--time-limit\",\n type=float,\n default=default_time,\n help=\"stop each search after this many seconds\" + hint,\n )\n\n\ndef _time_limit(args):\n limit = getattr(args, \"time_limit\", None)\n return limit if limit and limit > 0 else None\n\n\ndef _needs_heuristic(planner: str) -> bool:\n # Read from the registry at call time rather than INFORMED_PLANNERS, which\n # is fixed at import and so cannot know about a plugin's planners.\n return bool(getattr(PLANNERS.get(planner), \"requires_heuristic\", False))\n\n\ndef heuristic_spec(value: str) -> str:\n \"\"\"Validate ``-H``: a registry name, ``none``, or ``kind:argument``.\n\n ``choices=`` cannot express this \u2014 a trained heuristic is identified by a\n path that does not exist until someone trains one \u2014 so the check is a type\n function instead. It still rejects typos eagerly, which is the only reason\n ``choices=`` was worth having.\n \"\"\"\n if value in HEURISTICS or value == \"none\":\n return value\n kind, sep, argument = value.partition(\":\")\n if sep and kind in LOADERS:\n if not argument:\n raise argparse.ArgumentTypeError(\n f\"'{kind}:' needs an argument, e.g. {kind}:model.json\"\n )\n return value\n raise argparse.ArgumentTypeError(\n f\"unknown heuristic '{value}'; expected one of \"\n f\"{sorted(HEURISTICS) + ['none']} \"\n f\"or {'/'.join(sorted(LOADERS))}:\"\n )\n\n\ndef _add_solve(sub):\n p = sub.add_parser(\"solve\", help=\"solve a single PDDL instance\")\n p.add_argument(\"domain\")\n p.add_argument(\"problem\")\n p.add_argument(\"-s\", \"--search\", default=\"astar\", choices=sorted(PLANNERS))\n p.add_argument(\n \"-H\",\n \"--heuristic\",\n default=\"lmcut\",\n type=heuristic_spec,\n metavar=\"NAME\",\n help=\"a heuristic name, none, or learned:\",\n )\n p.add_argument(\n \"-w\", \"--weight\", type=float, default=2.0, help=\"weight for weighted A*\"\n )\n p.add_argument(\n \"--live\",\n action=\"store_true\",\n help=\"watch the search live in the terminal (no dependencies)\",\n )\n p.add_argument(\"--trace\", default=None, help=\"write the search trace as JSON\")\n p.add_argument(\n \"--plot\",\n default=None,\n help=\"write a four-panel search-progress chart (PNG; needs the viz extra)\",\n )\n p.add_argument(\n \"--tree\", default=None, help=\"write the radial search-wavefront chart (PNG)\"\n )\n p.add_argument(\n \"--plan-plot\", default=None, help=\"write the plan timeline chart (PNG)\"\n )\n p.add_argument(\"--dark\", action=\"store_true\", help=\"render charts for dark mode\")\n _add_budget(p)\n p.add_argument(\"--quiet\", action=\"store_true\", help=\"do not print the plan\")\n p.set_defaults(func=_cmd_solve)\n\n\ndef _add_benchmark(sub):\n p = sub.add_parser(\"benchmark\", help=\"compare planners over a folder of instances\")\n p.add_argument(\"root\", help=\"folder containing /domain.pddl + problem.pddl\")\n p.add_argument(\"--planners\", default=\"bfs,dijkstra,astar,gbfs,wastar,ehc\")\n p.add_argument(\"--heuristic\", default=\"hff\", help=\"heuristic for informed planners\")\n p.add_argument(\"--csv\", default=None, help=\"write per-run results to this CSV\")\n p.add_argument(\"--plot\", default=None, help=\"write a comparison bar chart (PNG)\")\n p.add_argument(\n \"--dashboard\",\n default=None,\n help=\"write the full benchmark dashboard: coverage, effort, time, heatmap\",\n )\n p.add_argument(\n \"--metric\",\n default=\"expanded\",\n choices=[\n \"expanded\",\n \"generated\",\n \"evaluated\",\n \"runtime\",\n \"cost\",\n \"plan_length\",\n ],\n help=\"what --plot compares\",\n )\n p.add_argument(\"--dark\", action=\"store_true\", help=\"render charts for dark mode\")\n # A default limit: one blind search on a large instance would otherwise\n # stall the whole table, which is what `benchmark demos` used to do.\n _add_budget(p, default_time=10.0)\n p.set_defaults(func=_cmd_benchmark)\n\n\ndef _add_animate(sub):\n p = sub.add_parser(\"animate\", help=\"replay a search as an animation (MP4 or GIF)\")\n p.add_argument(\"domain\")\n p.add_argument(\"problem\")\n p.add_argument(\"-o\", \"--output\", default=\"search.mp4\")\n p.add_argument(\"-s\", \"--search\", default=\"astar\", choices=sorted(PLANNERS))\n p.add_argument(\n \"-H\",\n \"--heuristic\",\n default=\"lmcut\",\n type=heuristic_spec,\n metavar=\"NAME\",\n help=\"a heuristic name, none, or learned:\",\n )\n p.add_argument(\"--fps\", type=int, default=30)\n p.add_argument(\"--seconds\", type=float, default=8.0)\n p.add_argument(\"--dark\", action=\"store_true\")\n _add_budget(p)\n p.set_defaults(func=_cmd_animate)\n\n\ndef _add_demo(sub):\n p = sub.add_parser(\n \"demo\",\n help=\"run the bundled demo instances and write every chart to a folder\",\n )\n p.add_argument(\"-o\", \"--output\", default=\"gallery\", help=\"output folder\")\n p.add_argument(\n \"--root\",\n default=\"demos\",\n help=\"folder of instances (default: demos/, in a source checkout)\",\n )\n p.add_argument(\n \"--both-modes\",\n action=\"store_true\",\n help=\"render a light and a dark variant of every chart\",\n )\n p.add_argument(\n \"--animate\", action=\"store_true\", help=\"also render the search animations\"\n )\n _add_budget(p, default_time=10.0)\n p.set_defaults(func=_cmd_demo)\n\n\n# --------------------------------------------------------------------------\ndef _observers(args):\n \"\"\"Build the observer for a solve, honouring --live/--trace/--plot.\"\"\"\n from .trace import MultiObserver, TraceRecorder\n\n wants_trace = bool(\n args.trace\n or args.plot\n or getattr(args, \"tree\", None)\n or getattr(args, \"plan_plot\", None)\n )\n recorder = TraceRecorder() if wants_trace else None\n dashboard = None\n if args.live:\n from .live import TerminalDashboard\n\n dashboard = TerminalDashboard()\n if recorder and dashboard:\n return MultiObserver(recorder, dashboard), recorder\n return (dashboard or recorder), recorder\n\n\ndef _cmd_solve(args) -> int:\n if args.plot or args.tree or args.plan_plot:\n _need_viz(\"--plot/--tree/--plan-plot\")\n task = build_task(args.domain, args.problem)\n heuristic = args.heuristic # \"none\" is resolved by solve_task\n kwargs = {\"weight\": args.weight} if args.search == \"wastar\" else {}\n observer, recorder = _observers(args)\n result = solve_task(\n task,\n args.search,\n heuristic,\n observer=observer,\n max_expansions=args.max_expansions,\n time_limit=_time_limit(args),\n **kwargs,\n )\n\n trace = recorder.trace if recorder else None\n if trace is not None:\n if args.trace:\n trace.save(args.trace)\n print(f\"Wrote trace to {args.trace}\")\n _write_charts(args, trace)\n\n if not result.solved:\n if not args.live:\n if result.truncated:\n print(\n \"No plan found within the budget \"\n \"(the instance may still be solvable).\"\n )\n else:\n print(\"No plan found.\")\n _print_stats(result)\n return EXIT_TRUNCATED if result.truncated else EXIT_NO_PLAN\n valid = validate_plan(task, result.plan)\n visible = task.visible_plan(result.plan)\n if not args.quiet:\n header = f\"Plan ({len(visible)} steps, cost {result.cost}\"\n if task.temporal:\n header += f\", makespan {task.makespan(result.plan):g}\"\n print(header + \"):\")\n for i, op in enumerate(visible):\n print(f\" {i + 1:3d}. {op.base_name}\")\n print(f\"Valid: {valid}\")\n if not args.live:\n _print_stats(result)\n return EXIT_SOLVED if valid else EXIT_INVALID_PLAN\n\n\ndef _write_charts(args, trace) -> None:\n targets = [\n (args.plot, \"plot_search_progress\"),\n (getattr(args, \"tree\", None), \"plot_search_tree\"),\n (getattr(args, \"plan_plot\", None), \"plot_plan_timeline\"),\n ]\n if not any(path for path, _ in targets):\n return\n from . import viz # _cmd_solve checked it is installed before searching\n\n for path, function in targets:\n if not path:\n continue\n getattr(viz, function)(trace, path, dark=args.dark)\n print(f\"Wrote {path}\")\n\n\ndef _cmd_benchmark(args) -> int:\n if args.plot or args.dashboard:\n _need_viz(\"--plot/--dashboard\")\n try:\n heuristic_spec(args.heuristic)\n except argparse.ArgumentTypeError as exc:\n raise CLIError(str(exc)) from None\n planners = [name.strip() for name in args.planners.split(\",\") if name.strip()]\n unknown = [name for name in planners if name not in PLANNERS]\n if unknown:\n raise CLIError(f\"unknown planner(s) {unknown}; available: {sorted(PLANNERS)}\")\n instances = discover_instances(args.root)\n if not instances:\n raise CLIError(f\"no instances found under {args.root}\")\n configs = [\n (planner, args.heuristic if _needs_heuristic(planner) else None)\n for planner in planners\n ]\n\n rows = run_benchmark(\n instances,\n configs,\n max_expansions=args.max_expansions,\n time_limit=_time_limit(args),\n )\n _print_summary(summarize(rows))\n if args.csv:\n to_csv(rows, args.csv)\n print(f\"\\nWrote per-run results to {args.csv}\")\n if args.plot:\n plot_summary(rows, args.plot, metric=args.metric)\n print(f\"Wrote plot to {args.plot}\")\n if args.dashboard:\n from .viz import plot_benchmark_dashboard\n\n plot_benchmark_dashboard(rows, args.dashboard, dark=args.dark)\n print(f\"Wrote dashboard to {args.dashboard}\")\n return 0\n\n\ndef _cmd_animate(args) -> int:\n _need_viz(\"animate\")\n from .viz import animate_search\n\n task = build_task(args.domain, args.problem)\n _, trace = trace_search(\n task,\n args.search,\n args.heuristic,\n max_expansions=args.max_expansions,\n time_limit=_time_limit(args),\n )\n animate_search(\n trace, args.output, dark=args.dark, fps=args.fps, seconds=args.seconds\n )\n print(f\"Wrote animation to {args.output}\")\n return 0\n\n\ndef _add_requirements(sub):\n p = sub.add_parser(\n \"requirements\",\n help=\"show which PDDL requirement flags are supported, and how\",\n )\n p.add_argument(\n \"--support\",\n default=None,\n help=\"filter by support level: native, compiled, partial or rejected\",\n )\n p.add_argument(\"--json\", action=\"store_true\", help=\"emit machine-readable JSON\")\n p.add_argument(\"--verbose\", action=\"store_true\", help=\"include the full notes\")\n p.set_defaults(func=_cmd_requirements)\n\n\ndef _add_plugins(sub):\n p = sub.add_parser(\n \"plugins\",\n help=\"list planners, heuristics and generators added by installed packages\",\n )\n p.set_defaults(func=_cmd_plugins)\n\n\ndef _cmd_plugins(args) -> int:\n rows = describe_plugins()\n if not rows:\n print(\"No plugins installed. See .docs/EXTENDING.md to write one.\")\n return 0\n for row in rows:\n where = row[\"source\"] or f\"SKIPPED: {row['error']}\"\n print(f\"{row['kind']:<10}{row['name']:<20}{where}\")\n return 0\n\n\ndef _add_generate(sub):\n from .generator import GENERATORS\n\n p = sub.add_parser(\n \"generate\", help=\"generate PDDL instances reproducibly from a seed\"\n )\n p.add_argument(\"kind\", choices=sorted(GENERATORS))\n p.add_argument(\"-o\", \"--output\", default=None, help=\"write into this folder\")\n p.add_argument(\"-n\", \"--size\", type=int, default=4, help=\"instance size\")\n p.add_argument(\"--seed\", type=int, default=0)\n p.add_argument(\n \"--count\",\n type=int,\n default=1,\n help=\"generate a ladder of instances with increasing size\",\n )\n p.add_argument(\n \"--step\", type=int, default=1, help=\"size increment between ladder rungs\"\n )\n p.set_defaults(func=_cmd_generate)\n\n\ndef _add_learn(sub):\n from .generator import GENERATORS\n\n p = sub.add_parser(\n \"learn\",\n help=\"train a heuristic from solved plans, then reinforce it on search cost\",\n description=(\n \"Generate a ladder of instances, solve the small ones, fit a network \"\n \"to the cost-to-go their plans reveal, and optionally tune it against \"\n \"the number of nodes search actually expands. Writes a model that \"\n \"'-H learned:' accepts everywhere.\"\n ),\n )\n p.add_argument(\"kind\", choices=sorted(GENERATORS))\n p.add_argument(\"-o\", \"--output\", default=None, help=\"write the model here\")\n p.add_argument(\n \"--sizes\",\n default=\"3-6\",\n help=\"training ladder, as 'lo-hi' (default 3-6). Keep these small: they \"\n \"have to be solvable optimally\",\n )\n p.add_argument(\n \"--seeds-per-size\",\n type=int,\n default=2,\n help=\"instances per rung; more seeds usually beats more rungs\",\n )\n p.add_argument(\"--seed\", type=int, default=0)\n p.add_argument(\"--epochs\", type=int, default=60)\n p.add_argument(\n \"--rank-weight\",\n type=float,\n default=0.8,\n help=\"share of the objective spent on ordering rather than magnitude; \"\n \"GBFS only reads the order\",\n )\n p.add_argument(\n \"--dagger\",\n type=int,\n default=0,\n metavar=\"ROUNDS\",\n help=\"retrain on the states the heuristic's own search visits\",\n )\n p.add_argument(\n \"--bootstrap\",\n default=None,\n metavar=\"LO-HI\",\n help=\"grow the corpus with harder instances as they become solvable\",\n )\n p.add_argument(\n \"--cem\",\n type=int,\n default=0,\n metavar=\"ITERATIONS\",\n help=\"optimise expansions directly (needs instances with headroom; \"\n \"see --cem-sizes)\",\n )\n p.add_argument(\n \"--cem-sizes\",\n default=None,\n metavar=\"LO-HI\",\n help=\"instances to tune search cost on; defaults to a rung above the \"\n \"training ladder, because the training ladder has no headroom left\",\n )\n p.add_argument(\n \"--evaluate\",\n default=None,\n metavar=\"LO-HI\",\n help=\"after training, benchmark against hff/goalcount on these sizes\",\n )\n p.set_defaults(func=_cmd_learn)\n\n\ndef _range(spec: str):\n \"\"\"Parse ``'3-6'`` or ``'5'`` into a range.\"\"\"\n text = str(spec).strip()\n try:\n if \"-\" in text:\n lo, _, hi = text.partition(\"-\")\n return range(int(lo), int(hi) + 1)\n return range(int(text), int(text) + 1)\n except ValueError:\n raise CLIError(f\"'{spec}' is not a size or a range like 3-6\") from None\n\n\ndef _cmd_learn(args) -> int:\n from .learn import RLConfig, TrainConfig, learn_heuristic\n from .learn.pipeline import (\n evaluate_transfer,\n summarise_transfer,\n tasks_from_generator,\n )\n\n output = args.output or f\"{args.kind}.heur.json\"\n try:\n bundle = learn_heuristic(\n args.kind,\n sizes=_range(args.sizes),\n seeds_per_size=args.seeds_per_size,\n seed=args.seed,\n train_config=TrainConfig(\n epochs=args.epochs, rank_weight=args.rank_weight, seed=args.seed\n ),\n rl_config=RLConfig(seed=args.seed, verbose=True),\n dagger_rounds=args.dagger,\n bootstrap_sizes=_range(args.bootstrap) if args.bootstrap else None,\n cem_iterations=args.cem,\n cem_sizes=_range(args.cem_sizes) if args.cem_sizes else None,\n verbose=True,\n )\n except RuntimeError as exc:\n print(f\"jupyddl: error: {exc}\", file=sys.stderr)\n return EXIT_BAD_INPUT\n\n bundle.save(output)\n print(f\"\\nWrote {output}\")\n print(\n f\" features {bundle.space.size} over {len(bundle.space.vocabulary)} predicates\"\n )\n print(f\" parameters {bundle.model.num_parameters}\")\n metrics = bundle.metrics\n print(\n f\" held out MAE {metrics.get('mae', 0):.2f}\"\n f\" top-1 {metrics.get('top1', 0):.3f}\"\n f\" over {metrics.get('held_out_instances', 0)} instances\"\n )\n print(f\"\\nUse it:\\n jupyddl solve -s gbfs -H learned:{output}\")\n\n if args.evaluate:\n tasks = tasks_from_generator(\n args.kind, _range(args.evaluate), seed=args.seed + 7777, seeds_per_size=2\n )\n print(f\"\\nBenchmark on {len(tasks)} unseen instances (gbfs):\")\n rows = evaluate_transfer(bundle, tasks)\n summary = summarise_transfer(rows)\n print(\n f\" {'heuristic':<12}{'coverage':>10}{'expanded':>12}\"\n f\"{'seconds':>10}{'cost':>8}\"\n )\n for name in [\"learned\"] + [k for k in summary if k != \"learned\"]:\n agg = summary[name]\n print(\n f\" {name:<12}{agg['coverage']:>10.2f}{agg['mean_expanded']:>12.0f}\"\n f\"{agg['mean_seconds']:>10.3f}{agg['mean_cost']:>8.1f}\"\n )\n return 0\n\n\ndef _cmd_requirements(args) -> int:\n from .requirements import as_rows, summary\n\n rows = as_rows()\n if args.support:\n rows = [row for row in rows if row[\"support\"] == args.support]\n if not rows:\n print(\n f\"No requirements with support level '{args.support}'.\", file=sys.stderr\n )\n return EXIT_BAD_INPUT\n\n if args.json:\n import json\n\n print(json.dumps({\"requirements\": rows, \"summary\": summary()}, indent=2))\n return 0\n\n counts = summary()\n print(\n f\"jupyddl PDDL support: {counts['native']} native, \"\n f\"{counts['compiled']} compiled, {counts['partial']} partial, \"\n f\"{counts['rejected']} rejected\\n\"\n )\n print(f\"{'requirement':<30}{'PDDL':<7}{'support':<11}summary\")\n print(\"-\" * 100)\n for row in rows:\n print(f\"{row['name']:<30}{row['pddl']:<7}{row['support']:<11}{row['summary']}\")\n if args.verbose and row[\"note\"]:\n for line in _wrap(row[\"note\"], 92):\n print(f\"{'':<48}{line}\")\n if not args.verbose:\n print(\"\\nRun with --verbose for the details of each compilation.\")\n return 0\n\n\ndef _wrap(text: str, width: int) -> list:\n words = text.split()\n lines, current = [], \"\"\n for word in words:\n if len(current) + len(word) + 1 > width:\n lines.append(current)\n current = word\n else:\n current = f\"{current} {word}\".strip()\n if current:\n lines.append(current)\n return lines\n\n\ndef _cmd_generate(args) -> int:\n from .generator import generate, write_instance\n\n sizes = [args.size + i * args.step for i in range(max(1, args.count))]\n if args.output is None:\n if len(sizes) > 1:\n print(\n \"Generating a ladder needs --output; a single instance can go \"\n \"to stdout but several cannot.\",\n file=sys.stderr,\n )\n return EXIT_BAD_INPUT\n domain, problem = generate(args.kind, size=args.size, seed=args.seed)\n print(\";; ---------- domain.pddl ----------\")\n print(domain)\n print(\";; ---------- problem.pddl ----------\")\n print(problem)\n return 0\n\n for size in sizes:\n folder = write_instance(args.kind, args.output, size=size, seed=args.seed)\n print(f\"Wrote {folder}\")\n return 0\n\n\ndef _cmd_demo(args) -> int:\n \"\"\"Render the whole gallery: per-instance charts plus a benchmark dashboard.\"\"\"\n _need_viz(\"demo\")\n from .viz import (\n plot_benchmark_dashboard,\n plot_plan_timeline,\n plot_planner_comparison,\n plot_search_progress,\n plot_search_tree,\n )\n\n instances = discover_instances(args.root)\n if not instances:\n raise CLIError(f\"no instances found under {args.root}\")\n budget = {\"max_expansions\": args.max_expansions, \"time_limit\": _time_limit(args)}\n os.makedirs(args.output, exist_ok=True)\n modes = [False, True] if args.both_modes else [False]\n\n configs = [(\"astar\", \"lmcut\"), (\"astar\", \"hmax\"), (\"gbfs\", \"hff\"), (\"bfs\", None)]\n for instance in instances:\n print(f\"-- {instance.name}\")\n try:\n task = build_task(instance.domain, instance.problem)\n except Exception as exc:\n print(f\" skipped: {type(exc).__name__}: {exc}\")\n continue\n traces = []\n for planner, heuristic in configs:\n try:\n _, trace = trace_search(task, planner, heuristic, **budget)\n traces.append(trace)\n except Exception as exc:\n print(f\" {planner}/{heuristic}: {type(exc).__name__}: {exc}\")\n if not traces:\n continue\n for dark in modes:\n suffix = \"-dark\" if dark else \"\"\n base = os.path.join(args.output, instance.name)\n plot_search_progress(traces[0], f\"{base}-progress{suffix}.png\", dark=dark)\n plot_search_tree(traces[0], f\"{base}-tree{suffix}.png\", dark=dark)\n plot_plan_timeline(traces[0], f\"{base}-plan{suffix}.png\", dark=dark)\n plot_planner_comparison(traces, f\"{base}-compare{suffix}.png\", dark=dark)\n if args.animate:\n from .viz import animate_search\n\n animate_search(\n traces[0], os.path.join(args.output, f\"{instance.name}-search.mp4\")\n )\n\n rows = run_benchmark(\n instances,\n [\n (\"astar\", \"lmcut\"),\n (\"astar\", \"hmax\"),\n (\"gbfs\", \"hff\"),\n (\"ehc\", \"hff\"),\n (\"bfs\", None),\n (\"dijkstra\", None),\n ],\n **budget,\n )\n to_csv(rows, os.path.join(args.output, \"benchmark.csv\"))\n for dark in modes:\n suffix = \"-dark\" if dark else \"\"\n plot_benchmark_dashboard(\n rows, os.path.join(args.output, f\"benchmark{suffix}.png\"), dark=dark\n )\n print(f\"\\nGallery written to {args.output}/\")\n return 0\n\n\ndef _print_stats(result) -> None:\n s = result.stats\n print(\n f\"Stats: expanded={s.expanded} generated={s.generated} \"\n f\"evaluated={s.evaluated} reopened={s.reopened} \"\n f\"deadends={s.deadends} runtime={s.runtime:.4f}s\"\n )\n\n\ndef _print_summary(summary) -> None:\n print(f\"{'config':<20}{'coverage':>12}{'expanded':>12}{'runtime(s)':>12}\")\n print(\"-\" * 56)\n for key, agg in summary.items():\n cov = f\"{agg['coverage']}/{agg['instances']}\"\n print(f\"{key:<20}{cov:>12}{agg['expanded']:>12}{agg['runtime']:>12.3f}\")\n\n\ndef main(argv=None) -> int:\n # Before the parser is built: ``--search`` and ``generate`` take their\n # ``choices`` from the registries, which is where plugins land.\n load_plugins()\n parser = argparse.ArgumentParser(\n prog=\"jupyddl\",\n description=\"Pure-Python PDDL planning framework.\",\n epilog=EXIT_CODES,\n formatter_class=argparse.RawDescriptionHelpFormatter,\n )\n parser.add_argument(\"--version\", action=\"version\", version=f\"jupyddl {__version__}\")\n sub = parser.add_subparsers(dest=\"command\", required=True)\n _add_solve(sub)\n _add_benchmark(sub)\n _add_animate(sub)\n _add_demo(sub)\n _add_requirements(sub)\n _add_generate(sub)\n _add_learn(sub)\n _add_plugins(sub)\n args = parser.parse_args(argv)\n try:\n return args.func(args)\n except (CLIError, PDDLError, OSError, ValueError, KeyError) as exc:\n # The user's input, not a bug: one line, not a traceback. A KeyError\n # here is a lookup by a name the user typed.\n if os.environ.get(\"JUPYDDL_DEBUG\"):\n raise\n message = exc.args[0] if isinstance(exc, KeyError) and exc.args else exc\n kind = \"\" if isinstance(exc, (CLIError, OSError)) else f\"{type(exc).__name__}: \"\n print(f\"jupyddl: error: {kind}{message}\", file=sys.stderr)\n return EXIT_BAD_INPUT\n\n\nif __name__ == \"__main__\": # pragma: no cover\n sys.exit(main())\n", "jupyddl/compile.py": "\"\"\"Compile PDDL 3 constructs down to the classical core.\n\nPreferences, trajectory constraints, timed initial literals and object fluents\nare all rewritten here, at the AST level, *before* grounding. The grounder and\nthe search never learn they existed \u2014 which is the point: one representation to\noptimise, and every new front-end feature is a source-to-source transformation\nrather than another special case in the hot loop.\n\nEach compilation is documented in :mod:`jupyddl.requirements`, including what it\ncosts you. The synthetic actions introduced along the way are all named with a\nleading ``__``; :attr:`jupyddl.task.Task.synthetic` collects them so a printed\nplan shows only the actions the domain author wrote.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import replace\n\nfrom .parser.ast import (\n Action,\n AddEffect,\n And,\n Atom,\n Comparison,\n ConjunctiveEffect,\n DelEffect,\n Domain,\n EqualityConstraint,\n Exists,\n Forall,\n ForallEffect,\n FluentRef,\n IncreaseCostEffect,\n Literal,\n Number,\n NumericEffect,\n Or,\n PDDLError,\n Predicate,\n Preference,\n Problem,\n Truth,\n UnsupportedFeatureError,\n WhenEffect,\n)\n\n# Every fact and action this module invents is prefixed, so they cannot collide\n# with a domain's own names and are easy to filter out of a plan.\nPREFIX = \"__\"\nCLOCK = FluentRef(\"__time\", ())\n\n__all__ = [\"compile_problem\", \"SYNTHETIC_PREFIX\"]\n\nSYNTHETIC_PREFIX = PREFIX\n\n\n# --------------------------------------------------------------------------\n# small AST helpers\n# --------------------------------------------------------------------------\ndef _fact(name: str, *args) -> Atom:\n return Atom(name, tuple(args))\n\n\ndef _holds(name: str, *args) -> Literal:\n return Literal(_fact(name, *args), True)\n\n\ndef _absent(name: str, *args) -> Literal:\n return Literal(_fact(name, *args), False)\n\n\ndef _conjoin(*parts):\n \"\"\"And() over the parts, dropping trivially-true ones.\"\"\"\n kept = [p for p in parts if p is not None and not isinstance(p, Truth)]\n if not kept:\n return Truth(True)\n if len(kept) == 1:\n return kept[0]\n return And(tuple(kept))\n\n\ndef _free():\n \"\"\"An explicit zero cost.\n\n Grounding charges 1 for any action without a cost effect, which is right for\n a domain action and wrong for the bookkeeping this module invents: closing\n the plan or observing that a constraint held must not show up in the metric.\n \"\"\"\n return IncreaseCostEffect(0.0)\n\n\ndef _also(effect, *extra):\n \"\"\"Append effects to an existing effect tree.\"\"\"\n parts = list(effect.parts) if isinstance(effect, ConjunctiveEffect) else [effect]\n parts.extend(extra)\n return ConjunctiveEffect(parts)\n\n\ndef _negate(formula):\n \"\"\"Negation normal form of ``not formula``.\"\"\"\n if isinstance(formula, Truth):\n return Truth(not formula.value)\n if isinstance(formula, Literal):\n return Literal(formula.atom, not formula.positive)\n if isinstance(formula, EqualityConstraint):\n return EqualityConstraint(formula.left, formula.right, not formula.positive)\n if isinstance(formula, Comparison):\n flip = {\"<\": \">=\", \"<=\": \">\", \">\": \"<=\", \">=\": \"<\", \"=\": \"!=\", \"!=\": \"=\"}\n return Comparison(flip[formula.op], formula.left, formula.right)\n if isinstance(formula, And):\n return Or(tuple(_negate(p) for p in formula.parts))\n if isinstance(formula, Or):\n return And(tuple(_negate(p) for p in formula.parts))\n if isinstance(formula, Forall):\n return Exists(formula.params, _negate(formula.body))\n if isinstance(formula, Exists):\n return Forall(formula.params, _negate(formula.body))\n raise PDDLError(f\"cannot negate {formula!r}\")\n\n\ndef _is_domain_action(action: Action) -> bool:\n return not action.name.startswith(PREFIX)\n\n\ndef _declare(domain: Domain, name: str, params=()) -> None:\n if all(p.name != name for p in domain.predicates):\n domain.predicates.append(Predicate(name, list(params)))\n\n\ndef _add_precondition(domain: Domain, formula, only_domain_actions=True) -> None:\n \"\"\"Conjoin ``formula`` onto every action's precondition.\"\"\"\n for index, action in enumerate(domain.actions):\n if only_domain_actions and not _is_domain_action(action):\n continue\n domain.actions[index] = replace(\n action, precondition=_conjoin(action.precondition, formula)\n )\n\n\ndef _add_conditional_effect(domain: Domain, condition, body) -> None:\n \"\"\"Give every domain action a ``when condition body`` effect.\n\n Used for monitors that must not be optional: the planner cannot decline to\n notice that a constraint's trigger became true.\n \"\"\"\n for index, action in enumerate(domain.actions):\n if not _is_domain_action(action):\n continue\n domain.actions[index] = replace(\n action, effect=_also(action.effect, WhenEffect(condition, body))\n )\n\n\n# --------------------------------------------------------------------------\n# object fluents\n# --------------------------------------------------------------------------\ndef compile_object_fluents(domain: Domain, problem: Problem):\n \"\"\"Turn ``(location ?p) - place`` into a predicate plus a uniqueness rule.\n\n ``(= (location ?p) ?x)`` becomes ``(__fn-location ?p ?x)``, and\n ``(assign (location ?p) ?y)`` clears the old value before setting the new\n one, so the predicate stays single-valued. Object fluents used as *nested\n terms* \u2014 ``(at ?t (location ?p))`` \u2014 are refused: flattening those needs a\n fresh existential per occurrence, and the equality form above expresses the\n same thing without the guesswork.\n \"\"\"\n if not domain.object_fluents:\n return domain, problem\n\n fluents = {fluent.name: fluent for fluent in domain.object_fluents}\n\n def predicate_name(name: str) -> str:\n return f\"{PREFIX}fn-{name}\"\n\n for fluent in domain.object_fluents:\n params = list(fluent.params) + [(\"?__value\", fluent.result_type)]\n _declare(domain, predicate_name(fluent.name), params)\n\n def rewrite_condition(formula):\n if isinstance(formula, Comparison) and formula.op in (\"=\", \"!=\"):\n left, right = formula.left, formula.right\n for a, b in ((left, right), (right, left)):\n if isinstance(a, FluentRef) and a.name in fluents:\n if isinstance(b, Number):\n raise UnsupportedFeatureError(\n f\"'{a.name}' returns an object, so it cannot be \"\n \"compared with a number\"\n )\n value = b.name if isinstance(b, FluentRef) else str(b)\n if isinstance(b, FluentRef) and b.name in fluents:\n raise UnsupportedFeatureError(\n \"comparing two object fluents directly is not \"\n \"supported; introduce a variable for one of them\"\n )\n atom = _fact(predicate_name(a.name), *a.args, value)\n return Literal(atom, formula.op == \"=\")\n return formula\n if isinstance(formula, And):\n return And(tuple(rewrite_condition(p) for p in formula.parts))\n if isinstance(formula, Or):\n return Or(tuple(rewrite_condition(p) for p in formula.parts))\n if isinstance(formula, Forall):\n return Forall(formula.params, rewrite_condition(formula.body))\n if isinstance(formula, Exists):\n return Exists(formula.params, rewrite_condition(formula.body))\n return formula\n\n def rewrite_effect(effect):\n if isinstance(effect, ConjunctiveEffect):\n return ConjunctiveEffect([rewrite_effect(p) for p in effect.parts])\n if isinstance(effect, ForallEffect):\n return ForallEffect(effect.params, rewrite_effect(effect.body))\n if isinstance(effect, WhenEffect):\n return WhenEffect(\n rewrite_condition(effect.condition), rewrite_effect(effect.body)\n )\n if isinstance(effect, NumericEffect) and effect.target.name in fluents:\n if effect.op != \"assign\":\n raise UnsupportedFeatureError(\n f\"'{effect.op}' is arithmetic, but '{effect.target.name}' \"\n \"returns an object; only 'assign' is meaningful\"\n )\n fluent = fluents[effect.target.name]\n name = predicate_name(fluent.name)\n value = effect.value\n new = value.name if isinstance(value, FluentRef) else str(value)\n # Clear whatever the function used to return, then set the new\n # value: that is what keeps it a function rather than a relation.\n clear = ForallEffect(\n [(\"?__old\", fluent.result_type)],\n WhenEffect(\n _holds(name, *effect.target.args, \"?__old\"),\n DelEffect(_fact(name, *effect.target.args, \"?__old\")),\n ),\n )\n return ConjunctiveEffect(\n [clear, AddEffect(_fact(name, *effect.target.args, new))]\n )\n return effect\n\n for index, action in enumerate(domain.actions):\n domain.actions[index] = replace(\n action,\n precondition=rewrite_condition(action.precondition),\n effect=rewrite_effect(action.effect),\n )\n for index, rule in enumerate(domain.derived):\n domain.derived[index] = replace(rule, body=rewrite_condition(rule.body))\n\n problem.goal = rewrite_condition(problem.goal)\n for index, preference in enumerate(problem.preferences):\n problem.preferences[index] = Preference(\n preference.name, rewrite_condition(preference.body)\n )\n\n # `(= (location p1) depot)` in :init becomes a plain fact.\n for fluent_ref, value in problem.init_objects.items():\n if fluent_ref.name not in fluents:\n raise PDDLError(\n f\"'{fluent_ref.name}' is assigned an object in :init but is not \"\n \"declared as an object fluent in :functions\"\n )\n problem.init.append(\n _fact(predicate_name(fluent_ref.name), *fluent_ref.args, value)\n )\n problem.init_objects = {}\n return domain, problem\n\n\n# --------------------------------------------------------------------------\n# timed initial literals\n# --------------------------------------------------------------------------\ndef compile_timed_initials(domain: Domain, problem: Problem):\n \"\"\"Give the model a clock, and make each timed literal fire off it.\n\n Elapsed time becomes the numeric fluent ``(__time)``, advanced by each\n durative action's duration. Every timed literal gets a zero-cost\n ``__fire-til-k`` action guarded by ``(>= (__time) t)``, and a\n ``__wait-til-k`` action that lets the planner advance the clock to ``t``\n when it wants the literal to happen.\n\n The literal must not be *skipped*: every domain action therefore carries\n ``(or (< (__time) t) (__til-k))`` for each timed literal, so once the clock\n passes ``t`` nothing else may happen until the literal has fired.\n\n Literals must also fire **in time order**, which is a separate constraint:\n ``(at 0 (open))`` and ``(at 3 (not (open)))`` describe a shop that opens then\n shuts, but firing them the other way round would leave it open forever. Each\n firing therefore requires every earlier literal to have fired already.\n\n Because actions do not overlap, a literal scheduled strictly inside an\n action's duration fires immediately after it rather than during it.\n \"\"\"\n if not problem.timed_initials:\n return domain, problem\n\n problem.init_numeric = dict(problem.init_numeric)\n problem.init_numeric.setdefault(CLOCK, 0.0)\n\n # The clock only moves if something moves it.\n for index, action in enumerate(domain.actions):\n if action.duration is None or not _is_domain_action(action):\n continue\n domain.actions[index] = replace(\n action,\n effect=_also(\n action.effect, NumericEffect(\"increase\", CLOCK, action.duration)\n ),\n )\n\n guards = []\n earlier: list = []\n for k, timed in enumerate(sorted(problem.timed_initials, key=lambda t: t.time)):\n marker = f\"{PREFIX}til-{k}\"\n _declare(domain, marker)\n due = Comparison(\">=\", CLOCK, Number(timed.time))\n not_due = Comparison(\"<\", CLOCK, Number(timed.time))\n # Everything scheduled before this must already have happened.\n in_order = _conjoin(*[_holds(name) for name in earlier])\n\n body = (\n AddEffect(timed.literal.atom)\n if timed.literal.positive\n else DelEffect(timed.literal.atom)\n )\n domain.actions.append(\n Action(\n f\"{PREFIX}fire-til-{k}\",\n [],\n _conjoin(due, _absent(marker), in_order),\n ConjunctiveEffect([body, AddEffect(_fact(marker)), _free()]),\n )\n )\n domain.actions.append(\n Action(\n f\"{PREFIX}wait-til-{k}\",\n [],\n # Waiting past an event that has not happened yet would skip it.\n _conjoin(not_due, in_order),\n ConjunctiveEffect(\n [NumericEffect(\"assign\", CLOCK, Number(timed.time)), _free()]\n ),\n )\n )\n guards.append(Or((not_due, _holds(marker))))\n earlier.append(marker)\n\n # A due literal blocks everything else until it has fired.\n for guard in guards:\n _add_precondition(domain, guard)\n problem.timed_initials = []\n return domain, problem\n\n\n# --------------------------------------------------------------------------\n# trajectory constraints\n# --------------------------------------------------------------------------\ndef compile_constraints(domain: Domain, problem: Problem):\n \"\"\"Compile ``(:constraints ...)`` into preconditions, monitors and goals.\n\n * ``always phi`` \u2014 conjoined onto every action's precondition and onto the\n goal. Every state on a plan's trajectory is either the initial state, a\n state an action is taken from, or the final state, so those three checks\n cover all of them.\n * ``at-end phi`` \u2014 conjoined onto the goal.\n * ``sometime phi`` \u2014 a zero-cost ``__observe`` action, applicable exactly\n when ``phi`` holds, sets a monitor fact the goal then requires.\n * ``sometime-before phi psi`` \u2014 the same monitor for ``psi``, plus\n ``always (phi implies monitor)``.\n * ``sometime-after phi psi`` \u2014 a *forced* monitor: every action records an\n outstanding obligation when ``phi`` holds without ``psi``, and discharges\n it when ``psi`` holds. The goal requires nothing outstanding.\n * ``at-most-once phi`` \u2014 forced monitors for \"phi has held\" and \"phi has\n since stopped\", plus ``always not (phi and stopped)``.\n\n Forced monitors ride on conditional effects, which the planner cannot\n decline; optional ones ride on actions, which it applies when convenient.\n The difference matters: a constraint the planner could satisfy by *not\n looking* would not be a constraint.\n \"\"\"\n constraints = list(domain.constraints) + list(problem.constraints)\n if not constraints:\n return domain, problem\n\n for constraint in constraints:\n if isinstance(constraint, Preference):\n raise UnsupportedFeatureError(\n f\"the soft constraint '{constraint.name}' is not supported: \"\n \"preferences are supported over goals, not over trajectory \"\n \"constraints\"\n )\n\n goal_parts = [problem.goal]\n invariants = []\n\n for index, constraint in enumerate(constraints):\n kind = constraint.kind\n if kind == \"always\":\n invariants.append(constraint.args[0])\n elif kind == \"at-end\":\n goal_parts.append(constraint.args[0])\n elif kind == \"sometime\":\n marker = _observation_monitor(domain, index, constraint.args[0])\n goal_parts.append(_holds(marker))\n elif kind == \"sometime-before\":\n trigger, earlier = constraint.args\n marker = _observation_monitor(domain, index, earlier)\n # \"phi implies the monitor\" as an invariant: by the time phi holds,\n # psi must already have been observed.\n invariants.append(Or((_negate(trigger), _holds(marker))))\n elif kind == \"sometime-after\":\n trigger, follower = constraint.args\n marker = f\"{PREFIX}pending-{index}\"\n _declare(domain, marker)\n _add_conditional_effect(\n domain,\n _conjoin(trigger, _negate(follower)),\n AddEffect(_fact(marker)),\n )\n _add_conditional_effect(domain, follower, DelEffect(_fact(marker)))\n goal_parts.append(_absent(marker))\n # ...and the final state must not leave a fresh obligation either.\n goal_parts.append(Or((_negate(trigger), follower)))\n elif kind == \"at-most-once\":\n phi = constraint.args[0]\n seen = f\"{PREFIX}amo-seen-{index}\"\n closed = f\"{PREFIX}amo-closed-{index}\"\n _declare(domain, seen)\n _declare(domain, closed)\n _add_conditional_effect(domain, phi, AddEffect(_fact(seen)))\n _add_conditional_effect(\n domain,\n _conjoin(_negate(phi), _holds(seen)),\n AddEffect(_fact(closed)),\n )\n # Holding again after an interval has closed is the second interval.\n invariants.append(Or((_negate(phi), _absent(closed))))\n else: # pragma: no cover - the parser rejects anything else\n raise UnsupportedFeatureError(f\"unsupported constraint '{kind}'\")\n\n for invariant in invariants:\n # Every action, not just the domain's own. The coverage argument above\n # holds only if each state on the trajectory is either taken from by an\n # action that checks the invariant or is the final state \u2014 and the\n # actions timed initial literals compile to *change facts*. Exempting\n # them let a plan step through a state the invariant forbade: a literal\n # that clears `(safe)` at t=3 and one that restores it at t=4 fire\n # back-to-back with nothing checking the state in between.\n _add_precondition(domain, invariant, only_domain_actions=False)\n goal_parts.append(invariant)\n\n problem.goal = _conjoin(*goal_parts)\n domain.constraints = []\n problem.constraints = []\n return domain, problem\n\n\ndef _observation_monitor(domain: Domain, index: int, formula) -> str:\n \"\"\"A fact the planner can set, for free, whenever ``formula`` holds.\"\"\"\n marker = f\"{PREFIX}seen-{index}\"\n _declare(domain, marker)\n domain.actions.append(\n Action(\n f\"{PREFIX}observe-{index}\",\n [],\n _conjoin(formula, _absent(marker)),\n ConjunctiveEffect([AddEffect(_fact(marker)), _free()]),\n )\n )\n return marker\n\n\n# --------------------------------------------------------------------------\n# preferences\n# --------------------------------------------------------------------------\ndef compile_preferences(domain: Domain, problem: Problem):\n \"\"\"Turn each soft goal into a priced choice: satisfy it, or pay for it.\n\n A closing phase makes this sound. ``__close`` ends the plan \u2014 every domain\n action requires that it has *not* happened \u2014 after which each preference is\n resolved by one of two zero-parameter actions: a free one that requires the\n preference to hold, or one costing the metric's ``(is-violated p)`` weight\n that does not. Cost-optimal search then picks whichever is cheaper, which is\n exactly what minimising the metric means.\n\n Freezing the state first is the point: without it the planner could satisfy\n a preference halfway through and then break it, and still be paid for it.\n \"\"\"\n if not problem.preferences:\n return domain, problem\n\n closed = f\"{PREFIX}closed\"\n _declare(domain, closed)\n _add_precondition(domain, _absent(closed))\n\n domain.actions.append(\n Action(\n f\"{PREFIX}close\",\n [],\n _absent(closed),\n ConjunctiveEffect([AddEffect(_fact(closed)), _free()]),\n )\n )\n\n goal_parts = [problem.goal, _holds(closed)]\n for index, preference in enumerate(problem.preferences):\n done = f\"{PREFIX}pref-{index}\"\n _declare(domain, done)\n weight = float(problem.violation_weights.get(preference.name, 1.0))\n if weight < 0:\n raise PDDLError(\n f\"preference '{preference.name}' has a negative violation \"\n \"weight, which would reward breaking it\"\n )\n domain.actions.append(\n Action(\n f\"{PREFIX}satisfy-{preference.name}\",\n [],\n _conjoin(_holds(closed), _absent(done), preference.body),\n ConjunctiveEffect([AddEffect(_fact(done)), _free()]),\n )\n )\n domain.actions.append(\n Action(\n f\"{PREFIX}violate-{preference.name}\",\n [],\n _conjoin(_holds(closed), _absent(done)),\n ConjunctiveEffect([AddEffect(_fact(done))]), # priced below\n )\n )\n # The penalty rides on the action cost, so plain cost-optimal search\n # optimises the metric without knowing what a preference is.\n domain.actions[-1] = replace(\n domain.actions[-1],\n effect=_also(domain.actions[-1].effect, _increase_cost(weight)),\n )\n goal_parts.append(_holds(done))\n\n problem.goal = _conjoin(*goal_parts)\n problem.preferences = []\n return domain, problem\n\n\ndef _increase_cost(amount: float):\n return IncreaseCostEffect(amount)\n\n\n# --------------------------------------------------------------------------\n# entry point\n# --------------------------------------------------------------------------\ndef compile_problem(domain: Domain, problem: Problem):\n \"\"\"Apply every PDDL 3 compilation, in the order they depend on each other.\n\n Object fluents go first because they rewrite terms everywhere else reads;\n preferences go last because their closing phase must sit outside the\n machinery the other compilations add.\n \"\"\"\n domain, problem = compile_object_fluents(domain, problem)\n domain, problem = compile_timed_initials(domain, problem)\n domain, problem = compile_constraints(domain, problem)\n domain, problem = compile_preferences(domain, problem)\n return domain, problem\n", "jupyddl/generator.py": "\"\"\"Generate PDDL domains and problems, reproducibly.\n\nBenchmarks live or die on their instance set, and hand-writing a scaling ladder\nis tedious. Each generator here takes a size and a ``seed`` and emits PDDL text,\nso a whole difficulty curve is one comprehension \u2014 and re-running the same seed\ngives byte-identical files, which is what makes a published experiment\nreproducible.\n\n::\n\n from jupyddl.generator import generate, GENERATORS\n\n domain, problem = generate(\"blocksworld\", size=10, seed=7)\n ladder = [generate(\"gripper\", size=n, seed=1) for n in range(2, 12)]\n\nEvery generator is exercised by the test suite, which grounds and solves what it\nproduces \u2014 a generator that emits unsolvable or unparseable PDDL is a bug.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport random\nfrom typing import Optional\n\n__all__ = [\n \"GENERATORS\",\n \"generate\",\n \"describe_generators\",\n \"generate_blocksworld\",\n \"generate_gripper\",\n \"generate_logistics\",\n \"generate_rovers\",\n \"generate_random_strips\",\n \"generate_numeric_transport\",\n \"generate_temporal_workshop\",\n]\n\n\ndef _objects(prefix: str, count: int) -> list:\n return [f\"{prefix}{i + 1}\" for i in range(count)]\n\n\n# --------------------------------------------------------------------------\n# blocksworld\n# --------------------------------------------------------------------------\nBLOCKSWORLD_DOMAIN = \"\"\"\\\n;; Blocksworld -- generated by jupyddl.generator\n(define (domain blocksworld)\n (:requirements :strips :typing)\n (:types block - object)\n (:predicates\n (on ?x - block ?y - block)\n (ontable ?x - block)\n (clear ?x - block)\n (handempty)\n (holding ?x - block))\n\n (:action pick-up\n :parameters (?x - block)\n :precondition (and (clear ?x) (ontable ?x) (handempty))\n :effect (and (not (ontable ?x)) (not (clear ?x))\n (not (handempty)) (holding ?x)))\n\n (:action put-down\n :parameters (?x - block)\n :precondition (holding ?x)\n :effect (and (not (holding ?x)) (clear ?x) (handempty) (ontable ?x)))\n\n (:action stack\n :parameters (?x - block ?y - block)\n :precondition (and (holding ?x) (clear ?y))\n :effect (and (not (holding ?x)) (not (clear ?y))\n (clear ?x) (handempty) (on ?x ?y)))\n\n (:action unstack\n :parameters (?x - block ?y - block)\n :precondition (and (on ?x ?y) (clear ?x) (handempty))\n :effect (and (holding ?x) (clear ?y) (not (clear ?x))\n (not (handempty)) (not (on ?x ?y)))))\n\"\"\"\n\n\ndef _random_towers(blocks, rng) -> list:\n \"\"\"Partition ``blocks`` into a random set of towers (bottom-first).\"\"\"\n shuffled = list(blocks)\n rng.shuffle(shuffled)\n towers: list = []\n index = 0\n while index < len(shuffled):\n height = rng.randint(1, max(1, len(shuffled) - index))\n towers.append(shuffled[index : index + height])\n index += height\n return towers\n\n\ndef _tower_facts(towers) -> list:\n facts = []\n for tower in towers:\n facts.append(f\"(ontable {tower[0]})\")\n for lower, upper in zip(tower, tower[1:]):\n facts.append(f\"(on {upper} {lower})\")\n facts.append(f\"(clear {tower[-1]})\")\n return facts\n\n\ndef generate_blocksworld(size: int = 6, seed: int = 0):\n \"\"\"``size`` blocks in random towers, to be rearranged into other towers.\"\"\"\n rng = random.Random(seed)\n blocks = _objects(\"b\", max(2, size))\n start = _random_towers(blocks, rng)\n goal_towers = _random_towers(blocks, rng)\n while goal_towers == start and len(blocks) > 2:\n goal_towers = _random_towers(blocks, rng)\n\n goal_facts = []\n for tower in goal_towers:\n goal_facts.append(f\"(ontable {tower[0]})\")\n for lower, upper in zip(tower, tower[1:]):\n goal_facts.append(f\"(on {upper} {lower})\")\n\n problem = f\"\"\"\\\n;; {len(blocks)} blocks, seed {seed} -- generated by jupyddl.generator\n(define (problem blocksworld-{len(blocks)}-{seed})\n (:domain blocksworld)\n (:objects {' '.join(blocks)} - block)\n (:init\n (handempty)\n {chr(10).join(' ' + f for f in _tower_facts(start)).strip()})\n (:goal (and\n {chr(10).join(' ' + f for f in goal_facts).strip()})))\n\"\"\"\n return BLOCKSWORLD_DOMAIN, problem\n\n\n# --------------------------------------------------------------------------\n# gripper\n# --------------------------------------------------------------------------\nGRIPPER_DOMAIN = \"\"\"\\\n;; Gripper -- generated by jupyddl.generator\n(define (domain gripper)\n (:requirements :strips :typing)\n (:types room ball gripper - object)\n (:predicates\n (at-robby ?r - room)\n (at ?b - ball ?r - room)\n (free ?g - gripper)\n (carry ?b - ball ?g - gripper))\n\n (:action move\n :parameters (?from - room ?to - room)\n :precondition (at-robby ?from)\n :effect (and (at-robby ?to) (not (at-robby ?from))))\n\n (:action pick\n :parameters (?b - ball ?r - room ?g - gripper)\n :precondition (and (at ?b ?r) (at-robby ?r) (free ?g))\n :effect (and (carry ?b ?g) (not (at ?b ?r)) (not (free ?g))))\n\n (:action drop\n :parameters (?b - ball ?r - room ?g - gripper)\n :precondition (and (carry ?b ?g) (at-robby ?r))\n :effect (and (at ?b ?r) (free ?g) (not (carry ?b ?g)))))\n\"\"\"\n\n\ndef generate_gripper(size: int = 4, seed: int = 0, grippers: int = 2):\n \"\"\"``size`` balls to move from room A to room B. Plan length grows linearly.\n\n Unlike every other generator here, ``seed`` does not vary the instance:\n in gripper every ball starts in room A and must end in room B, so\n ``size`` and ``grippers`` determine the problem completely. The seed\n reaches only the problem name, which keeps generated files distinct.\n\n The consequence is worth stating because it is easy to miss: a\n train/test split that varies the seed and holds the size fixed produces\n the *same instance* twice for this domain. Vary ``size`` instead.\n \"\"\"\n balls = _objects(\"ball\", max(1, size))\n hands = _objects(\"gripper\", max(1, grippers))\n init = [\"(at-robby rooma)\"]\n init += [f\"(free {g})\" for g in hands]\n init += [f\"(at {b} rooma)\" for b in balls]\n\n problem = f\"\"\"\\\n;; {len(balls)} balls, {len(hands)} grippers, seed {seed}\n(define (problem gripper-{len(balls)}-{seed})\n (:domain gripper)\n (:objects\n rooma roomb - room\n {' '.join(balls)} - ball\n {' '.join(hands)} - gripper)\n (:init\n {chr(10).join(' ' + f for f in init).strip()})\n (:goal (and\n {chr(10).join(f' (at {b} roomb)' for b in balls).strip()})))\n\"\"\"\n return GRIPPER_DOMAIN, problem\n\n\n# --------------------------------------------------------------------------\n# logistics\n# --------------------------------------------------------------------------\nLOGISTICS_DOMAIN = \"\"\"\\\n;; Logistics with action costs -- generated by jupyddl.generator\n(define (domain logistics)\n (:requirements :strips :typing :action-costs)\n (:types\n truck airplane - vehicle\n package vehicle - thing\n airport location - place\n city - object)\n (:predicates\n (at ?t - thing ?p - place)\n (in ?p - package ?v - vehicle)\n (in-city ?p - place ?c - city))\n (:functions (total-cost))\n\n (:action load\n :parameters (?p - package ?v - vehicle ?l - place)\n :precondition (and (at ?p ?l) (at ?v ?l))\n :effect (and (not (at ?p ?l)) (in ?p ?v) (increase (total-cost) 1)))\n\n (:action unload\n :parameters (?p - package ?v - vehicle ?l - place)\n :precondition (and (in ?p ?v) (at ?v ?l))\n :effect (and (not (in ?p ?v)) (at ?p ?l) (increase (total-cost) 1)))\n\n (:action drive\n :parameters (?t - truck ?from - place ?to - place ?c - city)\n :precondition (and (at ?t ?from) (in-city ?from ?c) (in-city ?to ?c))\n :effect (and (not (at ?t ?from)) (at ?t ?to) (increase (total-cost) 2)))\n\n (:action fly\n :parameters (?a - airplane ?from - airport ?to - airport)\n :precondition (at ?a ?from)\n :effect (and (not (at ?a ?from)) (at ?a ?to) (increase (total-cost) 6))))\n\"\"\"\n\n\ndef generate_logistics(size: int = 3, seed: int = 0, cities: int = 2):\n \"\"\"``size`` packages across ``cities`` cities, each with an airport and depot.\"\"\"\n rng = random.Random(seed)\n cities = max(2, cities)\n packages = _objects(\"pkg\", max(1, size))\n city_names = _objects(\"city\", cities)\n airports = [f\"apt{i + 1}\" for i in range(cities)]\n depots = [f\"depot{i + 1}\" for i in range(cities)]\n trucks = [f\"truck{i + 1}\" for i in range(cities)]\n\n init = [\"(= (total-cost) 0)\"]\n for i in range(cities):\n init.append(f\"(in-city {airports[i]} {city_names[i]})\")\n init.append(f\"(in-city {depots[i]} {city_names[i]})\")\n init.append(f\"(at {trucks[i]} {depots[i]})\")\n init.append(f\"(at plane1 {airports[0]})\")\n\n goals = []\n for package in packages:\n source = rng.randrange(cities)\n target = rng.randrange(cities)\n while target == source and cities > 1:\n target = rng.randrange(cities)\n init.append(f\"(at {package} {rng.choice([airports[source], depots[source]])})\")\n goals.append(f\"(at {package} {rng.choice([airports[target], depots[target]])})\")\n\n problem = f\"\"\"\\\n;; {len(packages)} packages, {cities} cities, seed {seed}\n(define (problem logistics-{len(packages)}-{seed})\n (:domain logistics)\n (:objects\n {' '.join(city_names)} - city\n {' '.join(trucks)} - truck\n plane1 - airplane\n {' '.join(packages)} - package\n {' '.join(airports)} - airport\n {' '.join(depots)} - location)\n (:init\n {chr(10).join(' ' + f for f in init).strip()})\n (:goal (and\n {chr(10).join(' ' + g for g in goals).strip()}))\n (:metric minimize (total-cost)))\n\"\"\"\n return LOGISTICS_DOMAIN, problem\n\n\n# --------------------------------------------------------------------------\n# rovers (ADL: disjunction + quantification)\n# --------------------------------------------------------------------------\nROVERS_DOMAIN = \"\"\"\\\n;; Rovers -- exercises disjunctive and quantified preconditions.\n(define (domain rovers)\n (:requirements :strips :typing :adl)\n (:types rover waypoint objective - object)\n (:predicates\n (at ?r - rover ?w - waypoint)\n (can-traverse ?a ?b - waypoint)\n (visible ?o - objective ?w - waypoint)\n (imaged ?o - objective)\n (sampled ?w - waypoint)\n (analysed ?w - waypoint)\n (reported ?o - objective))\n\n (:action navigate\n :parameters (?r - rover ?from ?to - waypoint)\n :precondition (and (at ?r ?from) (can-traverse ?from ?to))\n :effect (and (not (at ?r ?from)) (at ?r ?to)))\n\n (:action sample\n :parameters (?r - rover ?w - waypoint)\n :precondition (and (at ?r ?w) (not (sampled ?w)))\n :effect (sampled ?w))\n\n (:action analyse\n :parameters (?w - waypoint)\n :precondition (sampled ?w)\n :effect (analysed ?w))\n\n ;; An objective can be imaged from any waypoint that sees it.\n (:action image\n :parameters (?r - rover ?o - objective)\n :precondition (exists (?w - waypoint) (and (at ?r ?w) (visible ?o ?w)))\n :effect (imaged ?o))\n\n ;; Reporting accepts either a picture or a full sample analysis.\n (:action report\n :parameters (?o - objective)\n :precondition (or (imaged ?o)\n (exists (?w - waypoint)\n (and (visible ?o ?w) (analysed ?w))))\n :effect (reported ?o)))\n\"\"\"\n\n\ndef generate_rovers(size: int = 3, seed: int = 0, waypoints: int = 5):\n \"\"\"``size`` objectives over a connected waypoint graph. Uses `or` and `exists`.\"\"\"\n rng = random.Random(seed)\n waypoints = max(2, waypoints)\n points = _objects(\"wp\", waypoints)\n objectives = _objects(\"obj\", max(1, size))\n\n init = [\"(at rover1 wp1)\"]\n # A spanning path guarantees the graph is connected, so every instance is\n # solvable; the extra edges just give the planner choices.\n for a, b in zip(points, points[1:]):\n init.append(f\"(can-traverse {a} {b})\")\n init.append(f\"(can-traverse {b} {a})\")\n for _ in range(waypoints):\n a, b = rng.sample(points, 2)\n init.append(f\"(can-traverse {a} {b})\")\n\n for objective in objectives:\n for point in rng.sample(points, rng.randint(1, min(2, len(points)))):\n init.append(f\"(visible {objective} {point})\")\n\n problem = f\"\"\"\\\n;; {len(objectives)} objectives, {waypoints} waypoints, seed {seed}\n(define (problem rovers-{len(objectives)}-{seed})\n (:domain rovers)\n (:objects\n rover1 - rover\n {' '.join(points)} - waypoint\n {' '.join(objectives)} - objective)\n (:init\n {chr(10).join(' ' + f for f in sorted(set(init))).strip()})\n (:goal (and\n {chr(10).join(f' (reported {o})' for o in objectives).strip()})))\n\"\"\"\n return ROVERS_DOMAIN, problem\n\n\n# --------------------------------------------------------------------------\n# numeric transport\n# --------------------------------------------------------------------------\nNUMERIC_DOMAIN = \"\"\"\\\n;; Transport with fuel -- exercises numeric fluents.\n(define (domain numeric-transport)\n (:requirements :strips :typing :numeric-fluents)\n (:types truck location package - object)\n (:predicates\n (at ?t - truck ?l - location)\n (road ?a ?b - location)\n (carrying ?p - package ?t - truck)\n (package-at ?p - package ?l - location))\n (:functions\n (fuel ?t - truck)\n (distance ?a ?b - location))\n\n (:action drive\n :parameters (?t - truck ?from ?to - location)\n :precondition (and (at ?t ?from) (road ?from ?to)\n (>= (fuel ?t) (distance ?from ?to)))\n :effect (and (not (at ?t ?from)) (at ?t ?to)\n (decrease (fuel ?t) (distance ?from ?to))))\n\n (:action refuel\n :parameters (?t - truck)\n :precondition (< (fuel ?t) 40)\n :effect (assign (fuel ?t) 60))\n\n (:action load\n :parameters (?p - package ?t - truck ?l - location)\n :precondition (and (at ?t ?l) (package-at ?p ?l))\n :effect (and (not (package-at ?p ?l)) (carrying ?p ?t)))\n\n (:action unload\n :parameters (?p - package ?t - truck ?l - location)\n :precondition (and (at ?t ?l) (carrying ?p ?t))\n :effect (and (not (carrying ?p ?t)) (package-at ?p ?l))))\n\"\"\"\n\n\ndef generate_numeric_transport(size: int = 2, seed: int = 0, locations: int = 4):\n \"\"\"``size`` packages on a fuel-limited road network.\"\"\"\n rng = random.Random(seed)\n locations = max(2, locations)\n places = _objects(\"loc\", locations)\n packages = _objects(\"pkg\", max(1, size))\n\n init = [\"(at truck1 loc1)\", \"(= (fuel truck1) 50)\"]\n for a, b in zip(places, places[1:]):\n distance = rng.choice([10, 15, 20])\n init.append(f\"(road {a} {b})\")\n init.append(f\"(road {b} {a})\")\n init.append(f\"(= (distance {a} {b}) {distance})\")\n init.append(f\"(= (distance {b} {a}) {distance})\")\n\n goals = []\n for package in packages:\n init.append(f\"(package-at {package} {places[0]})\")\n goals.append(f\"(package-at {package} {places[-1]})\")\n\n problem = f\"\"\"\\\n;; {len(packages)} packages, {locations} locations, seed {seed}\n(define (problem numeric-transport-{len(packages)}-{seed})\n (:domain numeric-transport)\n (:objects\n truck1 - truck\n {' '.join(places)} - location\n {' '.join(packages)} - package)\n (:init\n {chr(10).join(' ' + f for f in init).strip()})\n (:goal (and\n {chr(10).join(' ' + g for g in goals).strip()})))\n\"\"\"\n return NUMERIC_DOMAIN, problem\n\n\n# --------------------------------------------------------------------------\n# temporal workshop\n# --------------------------------------------------------------------------\nTEMPORAL_DOMAIN = \"\"\"\\\n;; Workshop -- exercises durative actions (sequential compilation).\n(define (domain workshop)\n (:requirements :strips :typing :durative-actions)\n (:types part - object)\n (:predicates\n (cut ?p - part)\n (drilled ?p - part)\n (painted ?p - part)\n (finished ?p - part))\n\n (:durative-action cut\n :parameters (?p - part)\n :duration (= ?duration 2)\n :condition (and (at start (not (cut ?p))))\n :effect (and (at end (cut ?p))))\n\n (:durative-action drill\n :parameters (?p - part)\n :duration (= ?duration 3)\n :condition (and (over all (cut ?p)))\n :effect (and (at end (drilled ?p))))\n\n (:durative-action paint\n :parameters (?p - part)\n :duration (= ?duration 5)\n :condition (and (over all (drilled ?p)))\n :effect (and (at end (painted ?p))))\n\n (:durative-action inspect\n :parameters (?p - part)\n :duration (= ?duration 1)\n :condition (and (over all (painted ?p)))\n :effect (and (at end (finished ?p)))))\n\"\"\"\n\n\ndef generate_temporal_workshop(size: int = 2, seed: int = 0):\n \"\"\"``size`` parts through a cut/drill/paint/inspect pipeline, with durations.\"\"\"\n parts = _objects(\"part\", max(1, size))\n problem = f\"\"\"\\\n;; {len(parts)} parts, seed {seed}\n(define (problem workshop-{len(parts)}-{seed})\n (:domain workshop)\n (:objects {' '.join(parts)} - part)\n (:init )\n (:goal (and\n {chr(10).join(f' (finished {p})' for p in parts).strip()})))\n\"\"\"\n return TEMPORAL_DOMAIN, problem\n\n\n# --------------------------------------------------------------------------\n# random STRIPS\n# --------------------------------------------------------------------------\ndef generate_random_strips(\n size: int = 8,\n seed: int = 0,\n actions: int = 10,\n goal_size: int = 3,\n):\n \"\"\"A random STRIPS instance built backwards from a guaranteed solution.\n\n Purely random operators almost always give an unsolvable problem, which\n makes for a useless benchmark. Instead this plants a random *chain*: each\n step in the chain has an action that turns the previous state into the next,\n so a plan of known length exists. The remaining actions are noise the\n planner has to search past.\n \"\"\"\n rng = random.Random(seed)\n size = max(4, size)\n facts = [f\"p{i + 1}\" for i in range(size)]\n chain_length = max(2, min(actions // 2, size))\n\n init_facts = set(rng.sample(facts, max(1, size // 3)))\n state = set(init_facts)\n operators = []\n\n # --- the planted solution ---------------------------------------------\n for step in range(chain_length):\n candidates = [f for f in facts if f not in state]\n if not candidates:\n break\n added = rng.choice(candidates)\n precondition = sorted(rng.sample(sorted(state), min(2, len(state))))\n deleted = []\n if len(state) > 1 and rng.random() < 0.4:\n deleted = [\n (\n rng.choice([f for f in sorted(state) if f not in precondition])\n if len(state) > len(precondition)\n else None\n )\n ]\n deleted = [d for d in deleted if d]\n operators.append((f\"solve{step + 1}\", precondition, [added], deleted))\n state.add(added)\n state.difference_update(deleted)\n\n goal_facts = sorted(rng.sample(sorted(state), min(goal_size, len(state))))\n\n # --- distractors -------------------------------------------------------\n for index in range(max(0, actions - len(operators))):\n precondition = sorted(rng.sample(facts, rng.randint(1, 2)))\n added = sorted(rng.sample(facts, rng.randint(1, 2)))\n deleted = [f for f in rng.sample(facts, 1) if f not in added]\n operators.append((f\"noise{index + 1}\", precondition, added, deleted))\n\n def action_text(name, pre, add, delete):\n conditions = \" \".join(f\"({p})\" for p in pre) or \"\"\n effects = \" \".join(f\"({a})\" for a in add)\n effects += \" \" + \" \".join(f\"(not ({d}))\" for d in delete)\n return (\n f\" (:action {name}\\n\"\n f\" :precondition (and {conditions})\\n\"\n f\" :effect (and {effects.strip()}))\"\n )\n\n domain = \"\\n\".join(\n [\n \";; Random STRIPS -- generated by jupyddl.generator\",\n f\"(define (domain random-strips-{size}-{seed})\",\n \" (:requirements :strips)\",\n \" (:predicates \" + \" \".join(f\"({f})\" for f in facts) + \")\",\n \"\",\n \"\\n\\n\".join(action_text(*op) for op in operators),\n \")\",\n ]\n )\n problem = f\"\"\"\\\n;; {size} facts, {len(operators)} actions, seed {seed}\n(define (problem random-strips-{size}-{seed})\n (:domain random-strips-{size}-{seed})\n (:init {' '.join(f'({f})' for f in sorted(init_facts))})\n (:goal (and {' '.join(f'({f})' for f in goal_facts)})))\n\"\"\"\n return domain, problem\n\n\n# --------------------------------------------------------------------------\n# registry\n# --------------------------------------------------------------------------\nGENERATORS = {\n \"blocksworld\": generate_blocksworld,\n \"gripper\": generate_gripper,\n \"logistics\": generate_logistics,\n \"rovers\": generate_rovers,\n \"numeric-transport\": generate_numeric_transport,\n \"workshop\": generate_temporal_workshop,\n \"random-strips\": generate_random_strips,\n}\n\n# What each generator is meant to stress, for the CLI and the playground.\nGENERATOR_NOTES = {\n \"blocksworld\": \"Classic block stacking. Deep plans, heavy plateaus.\",\n \"gripper\": \"Balls between two rooms. High branching factor.\",\n \"logistics\": \"Trucks and a plane with action costs.\",\n \"rovers\": \"ADL: disjunctive and existential preconditions.\",\n \"numeric-transport\": \"Numeric fluents: fuel consumption and refuelling.\",\n \"workshop\": \"Durative actions; plans report a makespan.\",\n \"random-strips\": \"Random operators around a planted solution chain.\",\n}\n\n\ndef generate(kind: str, size: int = 4, seed: int = 0, **kwargs):\n \"\"\"Generate ``(domain_text, problem_text)`` for a named generator.\n\n An unregistered ``kind`` triggers plugin discovery once (see\n :mod:`jupyddl.plugins`) before it is reported as unknown.\n \"\"\"\n factory = GENERATORS.get(kind)\n if factory is None:\n from .plugins import load_plugins\n\n load_plugins()\n factory = GENERATORS.get(kind)\n if factory is None:\n raise ValueError(f\"Unknown generator '{kind}'. Available: {sorted(GENERATORS)}\")\n return factory(size=size, seed=seed, **kwargs)\n\n\ndef describe_generators() -> list:\n \"\"\"Serialisable metadata for the CLI and the web workbench.\"\"\"\n return [\n {\n \"name\": name,\n \"summary\": GENERATOR_NOTES.get(name)\n or (GENERATORS[name].__doc__ or \"\").strip().split(\"\\n\")[0],\n }\n for name in sorted(GENERATORS)\n ]\n\n\ndef write_instance(\n kind: str,\n folder: str,\n size: int = 4,\n seed: int = 0,\n name: Optional[str] = None,\n **kwargs,\n) -> str:\n \"\"\"Generate an instance and write ``//{domain,problem}.pddl``.\"\"\"\n import os\n\n domain, problem = generate(kind, size=size, seed=seed, **kwargs)\n target = os.path.join(folder, name or f\"{kind}-{size:02d}-{seed}\")\n os.makedirs(target, exist_ok=True)\n with open(os.path.join(target, \"domain.pddl\"), \"w\", encoding=\"utf-8\") as handle:\n handle.write(domain)\n with open(os.path.join(target, \"problem.pddl\"), \"w\", encoding=\"utf-8\") as handle:\n handle.write(problem)\n return target\n", "jupyddl/grounding.py": "\"\"\"Grounding: turn a parsed :class:`Domain` + :class:`Problem` into a\ngrounded :class:`~jupyddl.task.Task`.\n\nPipeline:\n\n1. Build the ``type -> objects`` table (with type-hierarchy closure).\n2. Instantiate every action over all type-consistent parameter tuples. The\n precondition formula is expanded (quantifiers over the object pool) and\n distributed into DNF; **each disjunct becomes its own grounded operator**, so\n the search only ever sees conjunctive preconditions.\n3. Detect static predicates (never added, deleted, or derived) and use the\n initial state to prune infeasible action instances and simplify conditions.\n4. Compile negative preconditions/goals into *positive normal form* by\n introducing complement facts ``(not ...)`` and maintaining them on every\n operator that touches the underlying atom.\n5. Ground derived-predicate rules into :class:`~jupyddl.task.Axiom` objects.\n6. Collect numeric fluents, compile their expressions into closures over the\n state's value vector, and encode everything as integer ids.\n\nA disjunctive goal is compiled to a single artificial goal fact achieved by one\nzero-cost operator per disjunct; those operators are recorded in\n``Task.synthetic`` so they can be hidden when a plan is printed.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom collections import defaultdict\nfrom dataclasses import dataclass, field\nfrom itertools import product\n\nfrom .parser.ast import (\n AddEffect,\n And,\n Arithmetic,\n Atom,\n Comparison,\n ConjunctiveEffect,\n DelEffect,\n Domain,\n EqualityConstraint,\n Exists,\n Forall,\n ForallEffect,\n FluentRef,\n IncreaseCostEffect,\n Literal,\n Number,\n NumericEffect,\n Or,\n PDDLError,\n Problem,\n Truth,\n UnsupportedFeatureError,\n WhenEffect,\n)\nfrom .compile import CLOCK as CLOCK_FLUENT\nfrom .compile import SYNTHETIC_PREFIX, compile_problem\nfrom .parser.parser import parse_domain_file, parse_problem_file\nfrom .task import Axiom, CondEffect, Operator, Task\n\n# Distributing a deeply disjunctive precondition into DNF can blow up\n# combinatorially. Stop with a clear message rather than exhausting memory.\nMAX_DISJUNCTS = 20000\n\nGOAL_FACT = Atom(\"__goal-reached__\", ())\n\n\ndef _ground_atom(atom: Atom, subst: dict) -> Atom:\n return Atom(atom.predicate, tuple(subst.get(a, a) for a in atom.args))\n\n\ndef _ground_fluent(ref: FluentRef, subst: dict) -> FluentRef:\n return FluentRef(ref.name, tuple(subst.get(a, a) for a in ref.args))\n\n\ndef _ground_expression(expr, subst: dict):\n if isinstance(expr, Number):\n return expr\n if isinstance(expr, FluentRef):\n return _ground_fluent(expr, subst)\n if isinstance(expr, Arithmetic):\n return Arithmetic(\n expr.op,\n _ground_expression(expr.left, subst),\n _ground_expression(expr.right, subst),\n )\n raise PDDLError(f\"unexpected numeric expression node: {expr!r}\")\n\n\ndef _build_type_objects(domain: Domain, problem: Problem):\n \"\"\"Map every type to the set of objects that inhabit it (hierarchy-aware).\"\"\"\n parent = dict(domain.types)\n\n def ancestors(typ: str):\n chain = [typ]\n seen = {typ}\n cur = typ\n while cur in parent and parent[cur] not in (None, \"object\", cur):\n cur = parent[cur]\n if cur in seen:\n break\n seen.add(cur)\n chain.append(cur)\n return chain\n\n type_objects: dict = defaultdict(set)\n declared: set = set()\n for name, typ in list(domain.constants) + list(problem.objects):\n declared.add(name)\n for anc in ancestors(typ):\n type_objects[anc].add(name)\n type_objects[\"object\"].add(name)\n\n # Robustness: some (toy) problems omit the :objects section and only mention\n # constants in :init/:goal. Treat any such undeclared constant as an object\n # of the root type so untyped domains still ground.\n for name in _harvest_constants(problem):\n if name not in declared:\n type_objects[\"object\"].add(name)\n\n for typ in set(list(parent) + list(parent.values())):\n type_objects.setdefault(typ, set())\n return {typ: tuple(sorted(objs)) for typ, objs in type_objects.items()}\n\n\ndef _harvest_constants(problem: Problem) -> set:\n found: set = set()\n for atom in problem.init:\n found.update(arg for arg in atom.args if not arg.startswith(\"?\"))\n for ref in problem.init_numeric:\n found.update(arg for arg in ref.args if not arg.startswith(\"?\"))\n\n def walk(formula):\n if isinstance(formula, Literal):\n found.update(a for a in formula.atom.args if not a.startswith(\"?\"))\n elif isinstance(formula, (And, Or)):\n for part in formula.parts:\n walk(part)\n elif isinstance(formula, (Exists, Forall)):\n walk(formula.body)\n\n walk(problem.goal)\n return found\n\n\n# --------------------------------------------------------------------------\n# condition formulas -> DNF\n# --------------------------------------------------------------------------\n@dataclass\nclass _Disjunct:\n \"\"\"One ground conjunction: positive atoms, negative atoms, comparisons.\"\"\"\n\n pos: set = field(default_factory=set)\n neg: set = field(default_factory=set)\n comparisons: list = field(default_factory=list)\n\n def merged(self, other: \"_Disjunct\") -> \"_Disjunct\":\n return _Disjunct(\n self.pos | other.pos,\n self.neg | other.neg,\n self.comparisons + other.comparisons,\n )\n\n @property\n def contradictory(self) -> bool:\n return bool(self.pos & self.neg)\n\n\nTRUE_DNF = [_Disjunct()]\nFALSE_DNF: list = []\n\n\ndef _dnf(formula, subst: dict, type_objects: dict) -> list:\n \"\"\"Expand quantifiers and distribute ``formula`` into a list of disjuncts.\n\n An empty list means *unsatisfiable*; a list holding one empty disjunct means\n *trivially true*.\n \"\"\"\n if formula is None:\n return TRUE_DNF\n\n if isinstance(formula, Truth):\n return TRUE_DNF if formula.value else FALSE_DNF\n\n if isinstance(formula, Literal):\n atom = _ground_atom(formula.atom, subst)\n if formula.positive:\n return [_Disjunct(pos={atom})]\n return [_Disjunct(neg={atom})]\n\n if isinstance(formula, EqualityConstraint):\n left = subst.get(formula.left, formula.left)\n right = subst.get(formula.right, formula.right)\n holds = (left == right) == formula.positive\n return TRUE_DNF if holds else FALSE_DNF\n\n if isinstance(formula, Comparison):\n grounded = Comparison(\n formula.op,\n _ground_expression(formula.left, subst),\n _ground_expression(formula.right, subst),\n )\n return [_Disjunct(comparisons=[grounded])]\n\n if isinstance(formula, And):\n result = TRUE_DNF\n for part in formula.parts:\n result = _cross(result, _dnf(part, subst, type_objects))\n if not result:\n return FALSE_DNF\n return result\n\n if isinstance(formula, Or):\n out: list = []\n for part in formula.parts:\n out.extend(_dnf(part, subst, type_objects))\n if len(out) > MAX_DISJUNCTS:\n raise UnsupportedFeatureError(\n \"disjunctive condition expands past \"\n f\"{MAX_DISJUNCTS} cases; simplify the domain or split the action\"\n )\n return out\n\n if isinstance(formula, Forall):\n result = TRUE_DNF\n for sub in _quantifier_substitutions(formula.params, subst, type_objects):\n result = _cross(result, _dnf(formula.body, sub, type_objects))\n if not result:\n return FALSE_DNF\n return result\n\n if isinstance(formula, Exists):\n out = []\n for sub in _quantifier_substitutions(formula.params, subst, type_objects):\n out.extend(_dnf(formula.body, sub, type_objects))\n if len(out) > MAX_DISJUNCTS:\n raise UnsupportedFeatureError(\n \"existential condition expands past \"\n f\"{MAX_DISJUNCTS} cases; the object pool is too large\"\n )\n return out\n\n raise PDDLError(f\"unexpected condition node: {formula!r}\")\n\n\ndef _quantifier_substitutions(params, subst: dict, type_objects: dict):\n pools = [type_objects.get(typ, ()) for (_, typ) in params]\n for combo in product(*pools):\n extended = dict(subst)\n for (var, _), obj in zip(params, combo):\n extended[var] = obj\n yield extended\n\n\ndef _cross(left: list, right: list) -> list:\n \"\"\"Distribute a conjunction of two DNFs, dropping contradictory disjuncts.\"\"\"\n if not left or not right:\n return FALSE_DNF\n out = []\n for a in left:\n for b in right:\n merged = a.merged(b)\n if not merged.contradictory:\n out.append(merged)\n if len(out) > MAX_DISJUNCTS:\n raise UnsupportedFeatureError(\n f\"conjunction of disjunctions expands past {MAX_DISJUNCTS} cases\"\n )\n return out\n\n\n# --------------------------------------------------------------------------\n# effects\n# --------------------------------------------------------------------------\n@dataclass\nclass _RawOp:\n name: str\n pre_pos: set\n pre_neg: set\n comparisons: list\n add: set\n delete: set\n cond: list # (cpos, cneg, cadd, cdel)\n numeric: list # (op, FluentRef, expression)\n cost: float\n duration: float\n synthetic: bool = False\n\n\ndef _collect_effect(eff, subst, type_objects, cpos, cneg, acc):\n if isinstance(eff, ConjunctiveEffect):\n for part in eff.parts:\n _collect_effect(part, subst, type_objects, cpos, cneg, acc)\n elif isinstance(eff, AddEffect):\n atom = _ground_atom(eff.atom, subst)\n if cpos or cneg:\n acc[\"cond\"].append((frozenset(cpos), frozenset(cneg), {atom}, set()))\n else:\n acc[\"add\"].add(atom)\n elif isinstance(eff, DelEffect):\n atom = _ground_atom(eff.atom, subst)\n if cpos or cneg:\n acc[\"cond\"].append((frozenset(cpos), frozenset(cneg), set(), {atom}))\n else:\n acc[\"delete\"].add(atom)\n elif isinstance(eff, IncreaseCostEffect):\n if cpos or cneg:\n # An operator has one cost. Charging this unconditionally would\n # bill for an effect that did not fire.\n raise UnsupportedFeatureError(\n \"(increase (total-cost) ...) inside a 'when' is not supported: \"\n \"an action's cost may not depend on the state\"\n )\n acc[\"cost\"] += eff.amount\n acc[\"has_cost\"] = True\n elif isinstance(eff, NumericEffect):\n if cpos or cneg:\n raise UnsupportedFeatureError(\n \"numeric effects inside a 'when' are not supported\"\n )\n acc[\"numeric\"].append(\n (\n eff.op,\n _ground_fluent(eff.target, subst),\n _ground_expression(eff.value, subst),\n )\n )\n elif isinstance(eff, ForallEffect):\n for sub in _quantifier_substitutions(eff.params, subst, type_objects):\n _collect_effect(eff.body, sub, type_objects, cpos, cneg, acc)\n elif isinstance(eff, WhenEffect):\n # A disjunctive effect condition splits into one conditional effect per\n # disjunct, which is exactly equivalent.\n for disjunct in _dnf(eff.condition, subst, type_objects):\n if disjunct.comparisons:\n raise UnsupportedFeatureError(\n \"numeric comparisons inside a 'when' condition are not supported\"\n )\n _collect_effect(\n eff.body,\n subst,\n type_objects,\n cpos | disjunct.pos,\n cneg | disjunct.neg,\n acc,\n )\n else:\n raise PDDLError(f\"unexpected effect node: {eff!r}\")\n\n\ndef _effect_predicates(domain: Domain) -> set:\n preds: set = set()\n\n def walk(eff):\n if isinstance(eff, ConjunctiveEffect):\n for part in eff.parts:\n walk(part)\n elif isinstance(eff, (AddEffect, DelEffect)):\n preds.add(eff.atom.predicate)\n elif isinstance(eff, (ForallEffect, WhenEffect)):\n walk(eff.body)\n\n for action in domain.actions:\n walk(action.effect)\n return preds\n\n\ndef _mentions_total_cost(expression) -> bool:\n if isinstance(expression, FluentRef):\n return expression.name == \"total-cost\"\n if isinstance(expression, Arithmetic):\n return _mentions_total_cost(expression.left) or _mentions_total_cost(\n expression.right\n )\n return False\n\n\ndef _ground_raw_operators(domain, problem, type_objects) -> list:\n # Under a metric over (total-cost), an action that never increases it is\n # free -- that is what the metric says, and what IPC planners do. Without\n # one, every action costs 1 and cost-optimal means shortest.\n action_costs = problem.metric is not None and _mentions_total_cost(\n problem.metric[1]\n )\n raw = []\n for action in domain.actions:\n pools = [type_objects.get(typ, ()) for (_, typ) in action.parameters]\n for combo in product(*pools):\n subst = {var: obj for (var, _), obj in zip(action.parameters, combo)}\n disjuncts = _dnf(action.precondition, subst, type_objects)\n if not disjuncts:\n continue # precondition is unsatisfiable for this instance\n\n acc = {\n \"add\": set(),\n \"delete\": set(),\n \"cond\": [],\n \"numeric\": [],\n \"cost\": 0.0,\n \"has_cost\": False,\n }\n _collect_effect(action.effect, subst, type_objects, set(), set(), acc)\n\n args = \",\".join(combo)\n base = f\"{action.name}({args})\" if combo else action.name\n duration = 0.0\n if action.duration is not None:\n duration = _constant_value(action.duration, subst, action.name)\n if acc[\"has_cost\"]:\n cost = acc[\"cost\"]\n elif action_costs:\n cost = 0\n elif duration:\n # A temporal action with no explicit cost: optimise makespan.\n cost = duration\n else:\n cost = 1\n if cost < 0:\n raise PDDLError(\n f\"action '{base}' has negative cost {cost}; \"\n \"(increase (total-cost) k) needs k >= 0\"\n )\n\n for index, disjunct in enumerate(disjuncts):\n # Only tag the name when the split is real, so classical domains\n # keep the operator names their users expect.\n name = base if len(disjuncts) == 1 else f\"{base}#{index + 1}\"\n raw.append(\n _RawOp(\n name=name,\n pre_pos=set(disjunct.pos),\n pre_neg=set(disjunct.neg),\n comparisons=list(disjunct.comparisons),\n add=set(acc[\"add\"]),\n delete=set(acc[\"delete\"]),\n cond=list(acc[\"cond\"]),\n numeric=list(acc[\"numeric\"]),\n cost=cost,\n duration=duration,\n )\n )\n return raw\n\n\ndef _constant_value(expr, subst, action_name):\n \"\"\"Evaluate a duration expression that must be constant.\"\"\"\n grounded = _ground_expression(expr, subst)\n if isinstance(grounded, Number):\n return float(grounded.value)\n raise UnsupportedFeatureError(\n f\"the duration of '{action_name}' must be a constant; \"\n \"durations that read numeric fluents are not supported\"\n )\n\n\n# --------------------------------------------------------------------------\n# numeric compilation\n# --------------------------------------------------------------------------\ndef _collect_fluents(expr, out: set) -> None:\n if isinstance(expr, FluentRef):\n out.add(expr)\n elif isinstance(expr, Arithmetic):\n _collect_fluents(expr.left, out)\n _collect_fluents(expr.right, out)\n\n\ndef _compile_expression(expr, index_of: dict):\n \"\"\"Compile a ground numeric expression into a ``values -> float`` closure.\"\"\"\n if isinstance(expr, Number):\n constant = float(expr.value)\n return lambda values: constant\n if isinstance(expr, FluentRef):\n index = index_of[expr]\n return lambda values: values[index]\n if isinstance(expr, Arithmetic):\n left = _compile_expression(expr.left, index_of)\n right = _compile_expression(expr.right, index_of)\n if expr.op == \"+\":\n return lambda values: left(values) + right(values)\n if expr.op == \"-\":\n return lambda values: left(values) - right(values)\n if expr.op == \"*\":\n return lambda values: left(values) * right(values)\n if expr.op == \"/\":\n\n def divide(values):\n denominator = right(values)\n if denominator == 0:\n # Undefined rather than crashing mid-search: an infinite\n # value makes every comparison against it fail.\n return float(\"inf\")\n return left(values) / denominator\n\n return divide\n raise PDDLError(f\"unknown arithmetic operator '{expr.op}'\")\n raise PDDLError(f\"unexpected numeric expression: {expr!r}\")\n\n\ndef _compile_comparison(comparison: Comparison, index_of: dict):\n left = _compile_expression(comparison.left, index_of)\n right = _compile_expression(comparison.right, index_of)\n op = comparison.op\n if op == \"<\":\n return lambda values: left(values) < right(values)\n if op == \"<=\":\n return lambda values: left(values) <= right(values)\n if op == \">\":\n return lambda values: left(values) > right(values)\n if op == \">=\":\n return lambda values: left(values) >= right(values)\n if op == \"=\":\n return lambda values: left(values) == right(values)\n if op == \"!=\":\n return lambda values: left(values) != right(values)\n raise PDDLError(f\"unknown comparison operator '{op}'\")\n\n\ndef _compile_numeric_effect(op: str, index: int, value, index_of: dict):\n compute = _compile_expression(value, index_of)\n if op == \"assign\":\n return (index, compute)\n if op == \"increase\":\n return (index, lambda values: values[index] + compute(values))\n if op == \"decrease\":\n return (index, lambda values: values[index] - compute(values))\n if op == \"scale-up\":\n return (index, lambda values: values[index] * compute(values))\n if op == \"scale-down\":\n\n def scale_down(values):\n divisor = compute(values)\n return float(\"inf\") if divisor == 0 else values[index] / divisor\n\n return (index, scale_down)\n raise PDDLError(f\"unknown numeric assignment '{op}'\")\n\n\n# --------------------------------------------------------------------------\n# encoding\n# --------------------------------------------------------------------------\n@dataclass\nclass _Encoder:\n fact_ids: dict = field(default_factory=dict)\n comp_ids: dict = field(default_factory=dict)\n names: list = field(default_factory=list)\n\n def fact(self, atom: Atom) -> int:\n if atom not in self.fact_ids:\n self.fact_ids[atom] = len(self.names)\n self.names.append(str(atom))\n return self.fact_ids[atom]\n\n def comp(self, atom: Atom) -> int:\n if atom not in self.comp_ids:\n self.comp_ids[atom] = len(self.names)\n self.names.append(f\"(not {atom})\")\n return self.comp_ids[atom]\n\n\ndef _ids(ids) -> frozenset:\n \"\"\"A fact-id set built in sorted order.\n\n A set of small ints iterates in insertion order wherever two ids share a\n hash slot, so a set filled while walking atoms in hash order iterates\n differently under each PYTHONHASHSEED -- enough to change which of two\n tied facts a heuristic picks.\n \"\"\"\n return frozenset(sorted(ids))\n\n\ndef _ground_axioms(domain: Domain, type_objects: dict) -> list:\n \"\"\"Ground every derived-predicate rule into (head atom, disjunct) pairs.\"\"\"\n grounded = []\n for rule in domain.derived:\n for subst in _quantifier_substitutions(rule.params, {}, type_objects):\n head = _ground_atom(rule.head, subst)\n for disjunct in _dnf(rule.body, subst, type_objects):\n if disjunct.comparisons:\n raise UnsupportedFeatureError(\n \"numeric comparisons in a derived predicate are not supported\"\n )\n grounded.append((head, disjunct))\n return grounded\n\n\ndef ground(domain: Domain, problem: Problem) -> Task:\n \"\"\"Ground ``domain`` + ``problem`` into a :class:`Task`.\"\"\"\n # PDDL 3 constructs (preferences, trajectory constraints, timed literals,\n # object fluents) are rewritten into the classical core first, so nothing\n # below this line has to know they exist.\n requirements = tuple(domain.requirements)\n domain, problem = compile_problem(domain, problem)\n type_objects = _build_type_objects(domain, problem)\n init_atoms = set(problem.init)\n\n derived_preds = {rule.head.predicate for rule in domain.derived}\n all_preds = {p.name for p in domain.predicates} | derived_preds\n # A derived predicate never appears in an effect, but it is emphatically not\n # static -- the axioms compute it.\n static_preds = all_preds - _effect_predicates(domain) - derived_preds\n\n def is_static(atom: Atom) -> bool:\n return atom.predicate in static_preds\n\n raw_ops = _ground_raw_operators(domain, problem, type_objects)\n axiom_rules = _ground_axioms(domain, type_objects)\n\n enc = _Encoder()\n tracked_neg: set = set()\n\n def resolve_literals(pos_atoms, neg_atoms):\n \"\"\"Drop static literals that hold, fail on ones that do not.\"\"\"\n pos_fluent, neg_fluent = set(), set()\n for atom in pos_atoms:\n if is_static(atom):\n if atom not in init_atoms:\n return None\n else:\n pos_fluent.add(atom)\n for atom in neg_atoms:\n if is_static(atom):\n if atom in init_atoms:\n return None\n else:\n neg_fluent.add(atom)\n tracked_neg.add(atom)\n return pos_fluent, neg_fluent\n\n # --- resolve static literals, drop infeasible operators ------------------\n resolved = []\n for op in raw_ops:\n pre = resolve_literals(op.pre_pos, op.pre_neg)\n if pre is None:\n continue\n pre_pos_fluent, pre_neg_fluent = pre\n\n add = set(op.add)\n delete = set(op.delete)\n cond = []\n for cpos, cneg, cadd, cdel in op.cond:\n trigger = resolve_literals(cpos, cneg)\n if trigger is None:\n continue # this conditional effect can never fire\n cpos_f, cneg_f = trigger\n if not cpos_f and not cneg_f:\n add |= cadd\n delete |= cdel\n else:\n cond.append((cpos_f, cneg_f, cadd, cdel))\n resolved.append((op, pre_pos_fluent, pre_neg_fluent, add, delete, cond))\n\n # --- numeric fluents that are never defined ------------------------------\n # A fluent with no value in :init that no action assigns stays undefined,\n # and PDDL makes any condition or effect that reads it fail. Reading it as\n # 0 instead would let `(distance a c)` for a road that does not exist cost\n # nothing. (A fluent defined later by an effect is not tracked.)\n assigned = {target for op, *_ in resolved for _, target, _ in op.numeric}\n defined = set(problem.init_numeric) | assigned\n\n def reads_undefined(comparisons, values=()) -> bool:\n read: set = set()\n for comparison in comparisons:\n _collect_fluents(comparison.left, read)\n _collect_fluents(comparison.right, read)\n for value in values:\n _collect_fluents(value, read)\n return any(ref not in defined and ref.name != \"total-cost\" for ref in read)\n\n resolved = [\n entry\n for entry in resolved\n if not reads_undefined(\n entry[0].comparisons, [value for _, _, value in entry[0].numeric]\n )\n ]\n\n # --- goal ----------------------------------------------------------------\n goal_disjuncts = _dnf(problem.goal, {}, type_objects)\n if not goal_disjuncts:\n raise ValueError(\"Goal condition is self-contradictory\")\n\n resolved_goals = []\n unsolvable = True\n for disjunct in goal_disjuncts:\n parts = resolve_literals(disjunct.pos, disjunct.neg)\n if parts is None or reads_undefined(disjunct.comparisons):\n continue # this way of satisfying the goal is statically impossible\n unsolvable = False\n resolved_goals.append((parts[0], parts[1], disjunct.comparisons))\n if not resolved_goals:\n resolved_goals = [(set(), set(), [])]\n\n # --- axioms --------------------------------------------------------------\n resolved_axioms = []\n for head, disjunct in axiom_rules:\n parts = resolve_literals(disjunct.pos, disjunct.neg)\n if parts is None:\n continue\n if any(atom.predicate in derived_preds for atom in parts[1]):\n raise UnsupportedFeatureError(\n f\"the rule for {head} negates a derived predicate; that needs \"\n \"stratified axiom evaluation, which is not supported (negating \"\n \"a derived predicate in a precondition or goal is fine)\"\n )\n resolved_axioms.append((head, parts[0], parts[1]))\n\n # --- numeric fluents -----------------------------------------------------\n fluents: set = set(problem.init_numeric)\n for op, *_ in resolved:\n for comparison in op.comparisons:\n _collect_fluents(comparison.left, fluents)\n _collect_fluents(comparison.right, fluents)\n for _, target, value in op.numeric:\n fluents.add(target)\n _collect_fluents(value, fluents)\n for _, _, comparisons in resolved_goals:\n for comparison in comparisons:\n _collect_fluents(comparison.left, fluents)\n _collect_fluents(comparison.right, fluents)\n # `total-cost` is bookkeeping handled by operator costs, not a state variable.\n fluents = {ref for ref in fluents if ref.name != \"total-cost\"}\n\n ordered_fluents = sorted(fluents, key=str)\n index_of = {ref: i for i, ref in enumerate(ordered_fluents)}\n numeric_names = tuple(str(ref) for ref in ordered_fluents)\n init_values = tuple(\n float(problem.init_numeric.get(ref, 0.0)) for ref in ordered_fluents\n )\n\n # --- encode facts --------------------------------------------------------\n # Fact ids are handed out in sorted order. Assigning them while walking\n # sets of atoms ties every id to string hashing, so PYTHONHASHSEED changed\n # LM-cut's tie-breaking and with it the expansions and the plan.\n every_atom: set = {a for a in init_atoms if not is_static(a)}\n for op, pp, pn, add, delete, cond in resolved:\n every_atom |= pp | pn | add | delete\n for cpos_f, cneg_f, cadd, cdel in cond:\n every_atom |= cpos_f | cneg_f | cadd | cdel\n for goal_pos, goal_neg, _ in resolved_goals:\n every_atom |= goal_pos | goal_neg\n for head, body_pos, body_neg in resolved_axioms:\n every_atom |= {head} | body_pos | body_neg\n for atom in sorted(every_atom, key=str):\n enc.fact(atom)\n for atom in sorted(tracked_neg, key=str):\n enc.comp(atom)\n\n init_ids = {enc.fact(a) for a in init_atoms if not is_static(a)}\n for atom in tracked_neg:\n cid = enc.comp(atom)\n if atom not in init_atoms:\n init_ids.add(cid)\n\n complement_ids = frozenset(enc.comp(a) for a in tracked_neg)\n\n def encode_add_del(add_atoms, del_atoms):\n add_ids = {enc.fact(a) for a in add_atoms}\n del_ids = {enc.fact(a) for a in del_atoms}\n for a in add_atoms:\n if a in tracked_neg:\n del_ids.add(enc.comp(a))\n for a in del_atoms:\n if a in tracked_neg:\n add_ids.add(enc.comp(a))\n # An atom both added and deleted ends up true (add wins), so its\n # complement must end up false: for complements the delete wins.\n add_ids -= del_ids & complement_ids\n return _ids(add_ids), _ids(del_ids)\n\n operators = []\n for op, pp, pn, add, delete, cond in resolved:\n precond = _ids({enc.fact(a) for a in pp} | {enc.comp(a) for a in pn})\n add_ids, del_ids = encode_add_del(add, delete)\n cond_effects = []\n touched = set(add_ids)\n for cpos_f, cneg_f, cadd, cdel in cond:\n cond_ids = {enc.fact(a) for a in cpos_f} | {enc.comp(a) for a in cneg_f}\n cadd_ids, cdel_ids = encode_add_del(cadd, cdel)\n if cadd_ids or cdel_ids:\n cond_effects.append(CondEffect(_ids(cond_ids), cadd_ids, cdel_ids))\n touched |= cadd_ids\n numeric_pre = tuple(_compile_comparison(c, index_of) for c in op.comparisons)\n numeric_eff = tuple(\n _compile_numeric_effect(kind, index_of[target], value, index_of)\n for kind, target, value in op.numeric\n )\n operators.append(\n Operator(\n op.name,\n precond,\n add_ids,\n del_ids,\n tuple(cond_effects),\n op.cost,\n numeric_pre,\n numeric_eff,\n op.duration,\n complements=(\n frozenset(touched) & complement_ids if cond_effects else frozenset()\n ),\n )\n )\n\n # --- goal encoding, compiling a disjunction into a synthetic fact --------\n synthetic: set = set()\n goal_numeric: tuple = ()\n if len(resolved_goals) == 1:\n goal_pos, goal_neg, comparisons = resolved_goals[0]\n goals = {enc.fact(a) for a in goal_pos} | {enc.comp(a) for a in goal_neg}\n goal_numeric = tuple(_compile_comparison(c, index_of) for c in comparisons)\n else:\n goal_fact = enc.fact(GOAL_FACT)\n goals = {goal_fact}\n for index, (goal_pos, goal_neg, comparisons) in enumerate(resolved_goals):\n name = f\"__reach-goal__#{index + 1}\"\n synthetic.add(name)\n precond = {enc.fact(a) for a in goal_pos} | {enc.comp(a) for a in goal_neg}\n operators.append(\n Operator(\n name,\n _ids(precond),\n frozenset({goal_fact}),\n frozenset(),\n (),\n 0,\n tuple(_compile_comparison(c, index_of) for c in comparisons),\n (),\n 0.0,\n )\n )\n\n if unsolvable:\n sentinel = len(enc.names)\n enc.names.append(\"(unsolvable)\")\n goals.add(sentinel) # never produced by any operator\n\n axioms = tuple(\n Axiom(\n enc.fact(head),\n _ids({enc.fact(a) for a in body_pos} | {enc.comp(a) for a in body_neg}),\n )\n for head, body_pos, body_neg in resolved_axioms\n )\n\n # Anything a compilation introduced is bookkeeping, not something the\n # domain author wrote, so keep it out of printed plans.\n synthetic |= {\n op.name for op in operators if op.base_name.startswith(SYNTHETIC_PREFIX)\n }\n temporal = any(op.duration for op in operators)\n clock_index = index_of.get(CLOCK_FLUENT)\n metric = None\n if problem.metric is not None:\n direction, expression = problem.metric\n metric = f\"{direction} {expression}\"\n\n # Before Task(...): encoding a derived atom no rule produces adds a name,\n # and `facts` below must already include it.\n derived_complements = tuple(\n sorted(\n (enc.fact(atom), enc.comp(atom))\n for atom in tracked_neg\n if atom.predicate in derived_preds\n )\n )\n\n return Task(\n name=problem.name or domain.name,\n facts=tuple(enc.names),\n init=_ids(init_ids),\n goals=_ids(goals),\n operators=tuple(operators),\n metric_cost=problem.metric_minimize_cost,\n axioms=axioms,\n numeric_names=numeric_names,\n init_values=init_values,\n goal_numeric=goal_numeric,\n temporal=temporal,\n clock_index=clock_index,\n metric=metric,\n requirements=requirements,\n synthetic=frozenset(synthetic),\n derived_complements=derived_complements,\n )\n\n\ndef ground_files(domain_path: str, problem_path: str) -> Task:\n \"\"\"Convenience: parse both files and ground them into a :class:`Task`.\"\"\"\n return ground(parse_domain_file(domain_path), parse_problem_file(problem_path))\n", "jupyddl/heuristics/__init__.py": "\"\"\"Heuristics and a name-based registry.\"\"\"\n\nfrom __future__ import annotations\n\nfrom functools import partial\n\nfrom .base import Heuristic\nfrom .critical_path import CriticalPathHeuristic\nfrom .delete_relaxation import FFHeuristic, HAddHeuristic, HMaxHeuristic\nfrom .lmcut import LMCutHeuristic\nfrom .simple import BlindHeuristic, GoalCountHeuristic\n\n# name -> callable(task) -> Heuristic\nHEURISTICS = {\n \"blind\": BlindHeuristic,\n \"goalcount\": GoalCountHeuristic,\n \"hmax\": HMaxHeuristic,\n \"hadd\": HAddHeuristic,\n \"hff\": FFHeuristic,\n \"lmcut\": LMCutHeuristic,\n \"h1\": partial(CriticalPathHeuristic, m=1),\n \"h2\": partial(CriticalPathHeuristic, m=2),\n \"hm\": CriticalPathHeuristic,\n}\n\n\n# A heuristic that needs more than a task to build takes an argument after a\n# colon: ``learned:blocksworld.heur.json``. Keeping this a *string* spec rather\n# than a Python object is what makes a trained heuristic usable from the CLI,\n# the benchmark harness and the web UI without any of them knowing it exists.\nLOADERS = {}\n\n\ndef _load_learned(path: str, task) -> Heuristic:\n \"\"\"Resolve ``learned:``, importing the learning stack only if asked.\"\"\"\n from ..learn.heuristic import HeuristicBundle\n\n return HeuristicBundle.load(path).bind(task)\n\n\nLOADERS[\"learned\"] = _load_learned\n\n\ndef make_heuristic(name, task) -> Heuristic:\n \"\"\"Instantiate a heuristic by name (see :data:`HEURISTICS`).\n\n Accepts a registry name (``\"lmcut\"``), a parameterised spec\n (``\"learned:model.json\"``), or an already-built heuristic, which is passed\n through so callers holding a trained model need not round-trip it to disk.\n An unregistered name triggers plugin discovery once (see\n :mod:`jupyddl.plugins`) before it is reported as unknown.\n \"\"\"\n if not isinstance(name, str):\n # A class or partial from HEURISTICS is a factory: build it for this\n # task. Anything else callable is an already-built ``state -> h``.\n if isinstance(name, (type, partial)):\n return name(task)\n if callable(name):\n return name\n raise TypeError(f\"not a heuristic: {name!r}\")\n if \":\" in name:\n kind, _, argument = name.partition(\":\")\n loader = LOADERS.get(kind)\n if loader is None:\n raise ValueError(\n f\"Unknown parameterised heuristic '{kind}'. \"\n f\"Available: {sorted(LOADERS)}\"\n )\n return loader(argument, task)\n factory = HEURISTICS.get(name)\n if factory is None:\n from ..plugins import load_plugins\n\n load_plugins()\n factory = HEURISTICS.get(name)\n if factory is None:\n raise ValueError(\n f\"Unknown heuristic '{name}'. Available: {sorted(HEURISTICS)}\"\n + (f\" (or {sorted(LOADERS)} with an argument)\" if LOADERS else \"\")\n )\n return factory(task)\n\n\n__all__ = [\n \"Heuristic\",\n \"BlindHeuristic\",\n \"GoalCountHeuristic\",\n \"HMaxHeuristic\",\n \"HAddHeuristic\",\n \"FFHeuristic\",\n \"LMCutHeuristic\",\n \"CriticalPathHeuristic\",\n \"HEURISTICS\",\n \"LOADERS\",\n \"make_heuristic\",\n]\n", "jupyddl/heuristics/base.py": "\"\"\"Heuristic base class.\"\"\"\n\nfrom __future__ import annotations\n\n\nclass Heuristic:\n \"\"\"A heuristic bound to a task; call it on a state to get an estimate.\n\n Returning ``math.inf`` signals that the state is a (relaxed) dead end.\n \"\"\"\n\n name: str = \"heuristic\"\n admissible: bool = False\n\n def __init__(self, task):\n self.task = task\n\n def __call__(self, state: frozenset) -> float: # pragma: no cover\n raise NotImplementedError\n", "jupyddl/heuristics/critical_path.py": "\"\"\"Critical-path heuristics h^m (Haslum & Geffner, 2000).\n\n``h^m`` estimates the cost of the most expensive size-``m`` subset of atoms.\n``h^1`` equals ``h_max``; higher ``m`` is more informative but costs more.\n\nThis computes ``h^m`` over the *delete relaxation*, as the relaxation\nheuristics here do: it is admissible on tasks without conditional effects, but\nit cannot see mutexes the way Haslum and Geffner's full ``h^m`` does.\nWith conditional effects and ``m >= 2`` it can overestimate, and the\ninstance's ``admissible`` flag says so.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport math\nfrom itertools import combinations\n\nfrom ..task import facts_of\nfrom .base import Heuristic\n\n\nclass CriticalPathHeuristic(Heuristic):\n name = \"hm\"\n admissible = True\n\n def __init__(self, task, m: int = 2):\n super().__init__(task)\n self.m = m\n self.goal = task.goals\n self.ops = list(task.relaxed_operators()) # (pre, add, cost)\n if m >= 2 and task.has_conditional_effects:\n self.admissible = False\n\n def __call__(self, state) -> float:\n return self._hm(state)\n\n def _hm(self, state) -> float:\n m = self.m\n inf = math.inf\n state = facts_of(state)\n atoms = set(state) | set(self.goal)\n for pre, add, _ in self.ops:\n atoms |= pre\n atoms |= add\n atoms = sorted(atoms)\n\n # Table of costs for every atom-set of size 1..m.\n table: dict = {}\n sets: list = []\n for size in range(1, m + 1):\n for combo in combinations(atoms, size):\n fs = frozenset(combo)\n table[fs] = 0.0 if fs <= state else inf\n sets.append(fs)\n\n def cost_of(subset: frozenset) -> float:\n # Cost of an arbitrary atom-set: table lookup, or (if larger than m)\n # the max over its size-m subsets.\n if not subset:\n return 0.0\n if len(subset) <= m:\n return table.get(subset, inf)\n best = 0.0\n for combo in combinations(sorted(subset), m):\n val = table.get(frozenset(combo), inf)\n if val > best:\n best = val\n return best\n\n # Value iteration to the fixpoint.\n changed = True\n while changed:\n changed = False\n for target in sets:\n if table[target] == 0.0:\n continue\n best = table[target]\n for pre, add, cost in self.ops:\n if not (add & target):\n continue\n regressed = (target - add) | pre\n val = cost + cost_of(regressed)\n if val < best:\n best = val\n if best < table[target]:\n table[target] = best\n changed = True\n\n return cost_of(frozenset(self.goal))\n", "jupyddl/heuristics/delete_relaxation.py": "\"\"\"Delete-relaxation heuristics: h_max, h_add and the FF heuristic.\"\"\"\n\nfrom __future__ import annotations\n\nimport math\nfrom collections import deque\n\nfrom .base import Heuristic\nfrom .relaxation import RelaxedTask, goal_value, propagate_costs\n\n\nclass HMaxHeuristic(Heuristic):\n \"\"\"h_max: the most expensive relaxed goal fact. Admissible.\"\"\"\n\n name = \"hmax\"\n admissible = True\n\n def __init__(self, task):\n super().__init__(task)\n self.rt = RelaxedTask(task)\n\n def __call__(self, state) -> float:\n cost, _ = propagate_costs(self.rt, state, additive=False)\n return goal_value(cost, self.rt.goal, additive=False)\n\n\nclass HAddHeuristic(Heuristic):\n \"\"\"h_add: sum of relaxed goal-fact costs. Informative, not admissible.\"\"\"\n\n name = \"hadd\"\n\n def __init__(self, task):\n super().__init__(task)\n self.rt = RelaxedTask(task)\n\n def __call__(self, state) -> float:\n cost, _ = propagate_costs(self.rt, state, additive=True)\n return goal_value(cost, self.rt.goal, additive=True)\n\n\nclass FFHeuristic(Heuristic):\n \"\"\"FF heuristic: cost of a relaxed plan extracted from the h_add graph.\"\"\"\n\n name = \"hff\"\n\n def __init__(self, task):\n super().__init__(task)\n self.rt = RelaxedTask(task)\n\n def __call__(self, state) -> float:\n cost, supporter = propagate_costs(self.rt, state, additive=True)\n if any(math.isinf(cost[g]) for g in self.rt.goal):\n return math.inf\n relaxed_plan: set = set()\n seen: set = set()\n queue = deque(self.rt.goal)\n while queue:\n fact = queue.popleft()\n if fact in state or fact in seen:\n continue\n seen.add(fact)\n op_idx = supporter[fact]\n if op_idx < 0:\n return math.inf\n if op_idx not in relaxed_plan:\n relaxed_plan.add(op_idx)\n for pre_fact in self.rt.ops[op_idx].pre:\n if pre_fact not in state:\n queue.append(pre_fact)\n return float(sum(self.rt.ops[i].cost for i in relaxed_plan))\n", "jupyddl/heuristics/lmcut.py": "\"\"\"The LM-cut heuristic (Helmert & Domshlak, 2009).\n\nLM-cut repeatedly computes h_max, finds a *landmark cut* of operators separating\nthe initial state from the goal in the justification graph, adds the cut's\ncheapest operator cost to the estimate, and discounts the cut operators. It is\none of the strongest admissible heuristics for optimal planning.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport heapq\nimport math\nfrom dataclasses import dataclass\n\nfrom .base import Heuristic\n\n\n@dataclass\nclass _AugOp:\n idx: int\n pre: frozenset\n add: frozenset\n base_cost: int\n\n\nclass LMCutHeuristic(Heuristic):\n name = \"lmcut\"\n admissible = True\n\n def __init__(self, task):\n super().__init__(task)\n # Each conditional effect is relaxed into its own operator charging the\n # full action cost, and the cost partitioning can then count it twice.\n if task.has_conditional_effects:\n self.admissible = False\n nf = task.num_facts\n self.INIT = nf # artificial \"always true\" fact\n self.GOAL = nf + 1 # artificial goal fact\n self.num = nf + 2\n\n self.ops: list[_AugOp] = []\n for pre, add, cost in task.relaxed_operators():\n # Every operator must have at least one precondition for the\n # justification graph; use the artificial INIT fact if needed.\n pre = pre if pre else frozenset({self.INIT})\n self.ops.append(_AugOp(len(self.ops), pre, add, cost))\n # Artificial goal operator (cost 0) collecting the real goal.\n self.goal_op = len(self.ops)\n goal_pre = task.goals if task.goals else frozenset({self.INIT})\n self.ops.append(_AugOp(self.goal_op, goal_pre, frozenset({self.GOAL}), 0))\n\n self.consumers: list[list[int]] = [[] for _ in range(self.num)]\n for op in self.ops:\n for f in op.pre:\n self.consumers[f].append(op.idx)\n\n def __call__(self, state) -> float:\n base = set(state)\n base.add(self.INIT)\n source = frozenset(base)\n costs = [op.base_cost for op in self.ops]\n total = 0.0\n\n while True:\n hmax, pcf = self._hmax(source, costs)\n if hmax[self.GOAL] == 0:\n return total\n if math.isinf(hmax[self.GOAL]):\n return math.inf\n\n goal_zone = self._goal_zone(costs, pcf)\n cut = self._cut(source, goal_zone, pcf, hmax)\n if not cut: # should not happen when hmax(goal) > 0\n return total # a lower bound either way; inf would prune\n min_cost = min(costs[oi] for oi in cut)\n total += min_cost\n for oi in cut:\n costs[oi] -= min_cost\n\n def _hmax(self, source, costs):\n inf = math.inf\n cost = [inf] * self.num\n pcf = [-1] * len(self.ops)\n counter = [len(op.pre) for op in self.ops]\n pq: list = []\n for f in source:\n cost[f] = 0\n heapq.heappush(pq, (0, f))\n\n def relax(op: _AugOp):\n # Ties go to the higher fact id, so the cut never depends on set order.\n supporter = max(op.pre, key=lambda p: (cost[p], p))\n value = cost[supporter] + costs[op.idx]\n pcf[op.idx] = supporter\n if math.isinf(cost[supporter]):\n return\n for f in op.add:\n if value < cost[f]:\n cost[f] = value\n heapq.heappush(pq, (value, f))\n\n for op in self.ops:\n if counter[op.idx] == 0:\n relax(op)\n while pq:\n c, f = heapq.heappop(pq)\n if c > cost[f]:\n continue\n for op_idx in self.consumers[f]:\n counter[op_idx] -= 1\n if counter[op_idx] == 0:\n relax(self.ops[op_idx])\n return cost, pcf\n\n def _goal_zone(self, costs, pcf):\n \"\"\"Facts that reach the goal through zero-cost justification edges.\"\"\"\n zone = {self.GOAL}\n changed = True\n while changed:\n changed = False\n for op in self.ops:\n supporter = pcf[op.idx]\n if (\n costs[op.idx] == 0\n and supporter >= 0\n and supporter not in zone\n and (op.add & zone)\n ):\n zone.add(supporter)\n changed = True\n return zone\n\n def _cut(self, source, goal_zone, pcf, hmax):\n \"\"\"Operators crossing from the init-reachable region into the goal zone.\"\"\"\n # Forward-reachable facts from the initial state that stay out of the\n # goal zone (the \"before\" region).\n before = {f for f in source if f not in goal_zone}\n changed = True\n while changed:\n changed = False\n for op in self.ops:\n supporter = pcf[op.idx]\n if supporter in before and not math.isinf(hmax[supporter]):\n for f in op.add:\n if f not in goal_zone and f not in before:\n before.add(f)\n changed = True\n cut = [\n op.idx for op in self.ops if pcf[op.idx] in before and (op.add & goal_zone)\n ]\n return cut\n", "jupyddl/heuristics/relaxation.py": "\"\"\"Delete-relaxation machinery shared by h_max, h_add, h_FF and LM-cut.\"\"\"\n\nfrom __future__ import annotations\n\nimport heapq\nimport math\nfrom dataclasses import dataclass\n\n\n@dataclass\nclass RelaxedOp:\n idx: int\n pre: frozenset\n add: frozenset\n cost: int\n\n\nclass RelaxedTask:\n \"\"\"Delete-relaxed view of a task: unary-ish operators + goal.\n\n Conditional effects are already expanded into separate relaxed operators by\n :meth:`jupyddl.task.Task.relaxed_operators`.\n \"\"\"\n\n def __init__(self, task):\n self.num_facts = task.num_facts\n self.goal = task.goals\n self.ops = [\n RelaxedOp(i, pre, add, cost)\n for i, (pre, add, cost) in enumerate(task.relaxed_operators())\n ]\n # Index: fact -> operators that have it as a precondition.\n self.consumers: list[list[int]] = [[] for _ in range(self.num_facts)]\n self.no_pre: list[int] = []\n for op in self.ops:\n if op.pre:\n for f in op.pre:\n self.consumers[f].append(op.idx)\n else:\n self.no_pre.append(op.idx)\n\n\ndef propagate_costs(rt: RelaxedTask, state, additive: bool):\n \"\"\"Generalised Dijkstra computing h_max (``additive=False``) or h_add costs.\n\n Returns ``(cost, supporter)`` where ``cost[f]`` is the estimated cost to\n achieve fact ``f`` and ``supporter[f]`` is the operator index that achieved\n it (used for FF's relaxed-plan extraction).\n \"\"\"\n inf = math.inf\n cost = [inf] * rt.num_facts\n supporter = [-1] * rt.num_facts\n counter = [len(op.pre) for op in rt.ops]\n pq: list = []\n\n for f in state:\n if cost[f] != 0:\n cost[f] = 0\n heapq.heappush(pq, (0, f))\n\n def op_value(op: RelaxedOp) -> float:\n if not op.pre:\n return op.cost\n pre_costs = [cost[p] for p in op.pre]\n agg = sum(pre_costs) if additive else max(pre_costs)\n return op.cost + agg\n\n def apply_op(op: RelaxedOp):\n value = op_value(op)\n if math.isinf(value):\n return\n for f in op.add:\n if value < cost[f]:\n cost[f] = value\n supporter[f] = op.idx\n heapq.heappush(pq, (value, f))\n\n for idx in rt.no_pre:\n apply_op(rt.ops[idx])\n\n while pq:\n c, f = heapq.heappop(pq)\n if c > cost[f]:\n continue\n for op_idx in rt.consumers[f]:\n counter[op_idx] -= 1\n if counter[op_idx] == 0:\n apply_op(rt.ops[op_idx])\n return cost, supporter\n\n\ndef goal_value(cost, goal, additive: bool) -> float:\n if not goal:\n return 0.0\n values = [cost[g] for g in goal]\n if any(math.isinf(v) for v in values):\n return math.inf\n return float(sum(values) if additive else max(values))\n", "jupyddl/heuristics/simple.py": "\"\"\"Cheap non-relaxation heuristics.\"\"\"\n\nfrom __future__ import annotations\n\nfrom ..task import facts_of\nfrom .base import Heuristic\n\n\nclass BlindHeuristic(Heuristic):\n \"\"\"0 in a goal state, otherwise the cheapest operator cost. Admissible.\n\n \"Cheapest\" includes zero-cost operators. Skipping them would make a single\n free action look like it costs something, and every PDDL 3 compilation\n adds free actions -- that overestimate cost A* its optimality.\n \"\"\"\n\n name = \"blind\"\n admissible = True\n\n def __init__(self, task):\n super().__init__(task)\n self.min_cost = min((op.cost for op in task.operators), default=0)\n\n def __call__(self, state) -> float:\n return 0.0 if self.task.goal_reached(state) else float(self.min_cost)\n\n\nclass GoalCountHeuristic(Heuristic):\n \"\"\"Number of unsatisfied goal facts. Fast, informative, not admissible.\"\"\"\n\n name = \"goalcount\"\n\n def __call__(self, state) -> float:\n return float(len(self.task.goals - facts_of(state)))\n", "jupyddl/live.py": "\"\"\"A live search dashboard that runs in any terminal, with no dependencies.\n\n:class:`TerminalDashboard` is a :class:`~jupyddl.trace.SearchObserver` that\nrepaints a small block of the terminal while the planner works \u2014 sparklines for\nthe heuristic and the ``f`` frontier, a frontier gauge, live counters and a node\nrate. It is pure standard library (ANSI escapes and Unicode block characters),\nso watching a search never costs you a dependency::\n\n from jupyddl import build_task, solve_task\n from jupyddl.live import TerminalDashboard\n\n task = build_task(\"domain.pddl\", \"problem.pddl\")\n solve_task(task, \"astar\", \"lmcut\", observer=TerminalDashboard())\n\nOn a non-interactive stream it degrades to a periodic one-line progress report,\nso it is also safe to use in CI logs and notebooks.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport math\nimport os\nimport re\nimport shutil\nimport sys\nimport time\n\nfrom .trace import SearchObserver\n\nSPARKS = \"\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588\"\nGAUGE_FULL = \"\u2588\"\nGAUGE_EMPTY = \"\u2591\"\n\n# 256-colour ANSI approximations of the jupyddl palette slots.\n_ANSI = {\n \"blue\": \"\\x1b[38;5;33m\",\n \"orange\": \"\\x1b[38;5;208m\",\n \"aqua\": \"\\x1b[38;5;36m\",\n \"yellow\": \"\\x1b[38;5;178m\",\n \"green\": \"\\x1b[38;5;34m\",\n \"muted\": \"\\x1b[38;5;245m\",\n \"bold\": \"\\x1b[1m\",\n \"reset\": \"\\x1b[0m\",\n}\n\n__all__ = [\"TerminalDashboard\", \"sparkline\"]\n\n\ndef sparkline(values, width: int = 40) -> str:\n \"\"\"Render ``values`` as a Unicode sparkline of at most ``width`` cells.\"\"\"\n values = [v for v in values if v is not None and not math.isinf(v)]\n if not values:\n return \"\"\n if len(values) > width: # keep the shape, drop the resolution\n bucket = len(values) / width\n values = [values[min(len(values) - 1, int(i * bucket))] for i in range(width)]\n low, high = min(values), max(values)\n if high == low:\n return SPARKS[3] * len(values)\n span = high - low\n return \"\".join(SPARKS[min(7, int((v - low) / span * 7.999))] for v in values)\n\n\ndef _gauge(fraction: float, width: int = 16) -> str:\n fraction = min(1.0, max(0.0, fraction))\n filled = int(round(fraction * width))\n return GAUGE_FULL * filled + GAUGE_EMPTY * (width - filled)\n\n\ndef _fmt(value) -> str:\n if value is None:\n return \"\u2013\"\n value = int(value)\n if value >= 1_000_000:\n return f\"{value / 1_000_000:.1f}M\"\n if value >= 10_000:\n return f\"{value / 1000:.1f}k\"\n return f\"{value:,}\"\n\n\nclass TerminalDashboard(SearchObserver):\n \"\"\"Repaint a live view of the search in the terminal.\n\n ``interval`` throttles repaints (seconds); ``history`` bounds the sparkline\n buffers so a multi-million-node search still costs constant memory.\n \"\"\"\n\n def __init__(\n self,\n stream=None,\n interval: float = 0.08,\n history: int = 400,\n color: bool = True,\n ):\n self.stream = stream if stream is not None else sys.stderr\n self.interval = interval\n self.history = history\n self.interactive = bool(getattr(self.stream, \"isatty\", lambda: False)())\n self.color = color and self.interactive\n\n self._h: list = []\n self._f: list = []\n self._open: list = []\n self._peak_open = 1\n self._best_h = math.inf\n self._start = time.perf_counter()\n self._last_paint = 0.0\n self._last_line = 0.0\n self._lines = 0\n self._title = \"jupyddl\"\n self._latest = None\n self._bounds = 0\n\n # ------------------------------------------------------------- painting\n def _c(self, key: str, text: str) -> str:\n if not self.color:\n return text\n return f\"{_ANSI[key]}{text}{_ANSI['reset']}\"\n\n def _width(self) -> int:\n # The stream's own terminal, not stdout's: the dashboard usually writes\n # to stderr, and the two can differ.\n try:\n columns = os.get_terminal_size(self.stream.fileno()).columns\n except (AttributeError, OSError, ValueError):\n columns = shutil.get_terminal_size().columns\n return max(20, min(columns - 2, 100))\n\n def _clear(self) -> None:\n if self._lines and self.interactive:\n self.stream.write(f\"\\x1b[{self._lines}A\\x1b[0J\")\n\n def _render(self, final: bool = False) -> None:\n width = self._width()\n spark_width = max(16, width - 30)\n event = self._latest\n expanded = event.expanded if event else 0\n generated = event.generated if event else 0\n evaluated = event.evaluated if event else 0\n elapsed = max(1e-9, time.perf_counter() - self._start)\n rate = expanded / elapsed\n\n current_h = self._h[-1] if self._h else None\n current_f = self._f[-1] if self._f else None\n open_now = self._open[-1] if self._open else 0\n best_h = None if math.isinf(self._best_h) else self._best_h\n\n lines = [\n self._c(\"bold\", self._title),\n self._c(\"muted\", \"\u2500\" * width),\n \" h \"\n + self._c(\"aqua\", sparkline(self._h, spark_width))\n + self._c(\"muted\", f\" now {_num(current_h)} best {_num(best_h)}\"),\n \" f \"\n + self._c(\"blue\", sparkline(self._f, spark_width))\n + self._c(\"muted\", f\" now {_num(current_f)}\"),\n \" frontier \"\n + self._c(\"orange\", _gauge(open_now / max(1, self._peak_open), 18))\n + self._c(\"muted\", f\" {_fmt(open_now)} (peak {_fmt(self._peak_open)})\"),\n \" \"\n + self._c(\"bold\", _fmt(expanded))\n + self._c(\"muted\", \" expanded \")\n + self._c(\"bold\", _fmt(generated))\n + self._c(\"muted\", \" generated \")\n + self._c(\"bold\", _fmt(evaluated))\n + self._c(\"muted\", \" evaluated\"),\n self._c(\"muted\", f\" {_fmt(rate)} nodes/s \u00b7 {elapsed:.2f}s\")\n + (self._c(\"muted\", f\" \u00b7 {self._bounds} bounds\") if self._bounds else \"\"),\n ]\n # A line wider than the terminal wraps, and the cursor-up that erases\n # the previous frame then climbs too few rows, smearing every repaint.\n lines = [_clip(line, width + 1) for line in lines]\n self._clear()\n self.stream.write(\"\\n\".join(lines) + \"\\n\")\n self.stream.flush()\n self._lines = len(lines)\n\n def _maybe_paint(self) -> None:\n now = time.perf_counter()\n if now - self._last_paint < self.interval:\n return\n self._last_paint = now\n if self.interactive:\n self._render()\n else:\n # Non-tty: a single appended line, at most every two seconds.\n if now - self._start > 1 and now - self._last_line >= 2.0:\n event = self._latest\n if event is not None:\n self._last_line = now\n self.stream.write(\n f\" ... {_fmt(event.expanded)} expanded, \"\n f\"{_fmt(event.generated)} generated, \"\n f\"{now - self._start:.1f}s\\n\"\n )\n self.stream.flush()\n\n # -------------------------------------------------------- observer hooks\n def on_start(self, task, planner: str, heuristic: str = \"\") -> None:\n self._start = time.perf_counter()\n label = f\"{planner}/{heuristic}\" if heuristic else planner\n name = getattr(task, \"name\", \"\") or \"task\"\n facts = getattr(task, \"num_facts\", 0)\n operators = len(getattr(task, \"operators\", ()))\n self._title = (\n f\"jupyddl \u00b7 {label} \u00b7 {name} \"\n f\"({facts} facts, {operators} ground actions)\"\n )\n if self.interactive:\n self._render()\n\n def on_expand(\n self,\n state,\n g: float = 0.0,\n h: float = 0.0,\n f: float = 0.0,\n depth: int = 0,\n open_size: int = 0,\n stats=None,\n parent=None,\n action: str = \"\",\n ) -> None:\n self._latest = stats\n if h is not None and not math.isinf(h):\n self._h.append(h)\n self._best_h = min(self._best_h, h)\n self._f.append(f if f else g + (h or 0))\n self._open.append(open_size)\n self._peak_open = max(self._peak_open, open_size)\n for buffer in (self._h, self._f, self._open):\n if len(buffer) > self.history:\n del buffer[: len(buffer) - self.history]\n self._maybe_paint()\n\n def on_bound(self, threshold: float, iteration: int, stats=None) -> None:\n self._bounds += 1\n self._latest = stats or self._latest\n\n def on_finish(self, result) -> None:\n self._latest = getattr(result, \"stats\", None) or self._latest\n if self.interactive:\n self._render(final=True)\n elapsed = time.perf_counter() - self._start\n if result is not None and getattr(result, \"solved\", False):\n verdict = self._c(\n \"green\",\n f\" \u2714 solved \u00b7 cost {result.cost} \u00b7 \"\n f\"{result.plan_length} actions \u00b7 {elapsed:.2f}s\",\n )\n elif getattr(result, \"truncated\", False):\n # Not \"no plan\": the budget ran out before the search finished.\n verdict = self._c(\n \"orange\", f\" \u23f8 stopped by the budget, no plan yet \u00b7 {elapsed:.2f}s\"\n )\n else:\n verdict = self._c(\"orange\", f\" \u2718 no plan found \u00b7 {elapsed:.2f}s\")\n self.stream.write(verdict + \"\\n\")\n self.stream.flush()\n self._lines = 0\n\n\n_ESCAPE = re.compile(r\"\\x1b\\[[0-9;]*m\")\n\n\ndef _clip(line: str, width: int) -> str:\n \"\"\"Cut ``line`` to ``width`` visible characters, keeping colour codes intact.\"\"\"\n out, visible, pos = [], 0, 0\n for match in _ESCAPE.finditer(line):\n text = line[pos : match.start()]\n if visible + len(text) > width:\n out.append(text[: width - visible])\n return \"\".join(out) + \"\\x1b[0m\"\n out.append(text)\n visible += len(text)\n out.append(match.group())\n pos = match.end()\n tail = line[pos:]\n if visible + len(tail) > width:\n return \"\".join(out) + tail[: width - visible] + (\"\\x1b[0m\" if out else \"\")\n return \"\".join(out) + tail\n\n\ndef _num(value) -> str:\n if value is None:\n return \"\u2013\"\n if isinstance(value, float):\n if math.isinf(value):\n return \"\u221e\"\n if value.is_integer():\n return str(int(value))\n return f\"{value:.1f}\"\n return str(value)\n", "jupyddl/parser/__init__.py": "\"\"\"PDDL parsing: tokenizer, AST and recursive-descent parser.\"\"\"\n\nfrom .ast import (\n Action,\n AddEffect,\n And,\n Arithmetic,\n Atom,\n Comparison,\n Conjunct,\n ConjunctiveEffect,\n DelEffect,\n DerivedPredicate,\n Domain,\n EqualityConstraint,\n Exists,\n Forall,\n ForallEffect,\n FluentRef,\n Function,\n IncreaseCostEffect,\n Literal,\n Number,\n NumericEffect,\n Or,\n PDDLError,\n Predicate,\n Problem,\n Truth,\n UnsupportedFeatureError,\n WhenEffect,\n)\nfrom .parser import (\n parse,\n parse_condition,\n parse_domain,\n parse_domain_file,\n parse_effect,\n parse_problem,\n parse_problem_file,\n)\nfrom .tokenizer import tokenize\n\n__all__ = [\n \"Action\",\n \"AddEffect\",\n \"And\",\n \"Arithmetic\",\n \"Atom\",\n \"Comparison\",\n \"Conjunct\",\n \"ConjunctiveEffect\",\n \"DelEffect\",\n \"DerivedPredicate\",\n \"Domain\",\n \"EqualityConstraint\",\n \"Exists\",\n \"Forall\",\n \"ForallEffect\",\n \"FluentRef\",\n \"Function\",\n \"IncreaseCostEffect\",\n \"Literal\",\n \"Number\",\n \"NumericEffect\",\n \"Or\",\n \"PDDLError\",\n \"Predicate\",\n \"Problem\",\n \"Truth\",\n \"UnsupportedFeatureError\",\n \"WhenEffect\",\n \"parse\",\n \"parse_condition\",\n \"parse_domain\",\n \"parse_effect\",\n \"parse_problem\",\n \"parse_domain_file\",\n \"parse_problem_file\",\n \"tokenize\",\n]\n", "jupyddl/parser/ast.py": "\"\"\"Structured AST for PDDL.\n\nConditions are stored as a **formula tree in negation normal form**: the parser\npushes every ``not`` down to the atoms and rewrites ``imply``, so the grounder\nonly ever sees negation applied to a literal. Quantifiers stay in the tree\nbecause expanding them needs the object pool, which only exists at grounding\ntime; the grounder then distributes the formula into DNF and emits one operator\nper disjunct.\n\nNumeric fluents and durative actions have their own small node types. See\n:mod:`jupyddl.requirements` for exactly which PDDL requirement flags are\nsupported and how.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass, field\n\n\nclass PDDLError(Exception):\n \"\"\"Base class for all parsing / modelling errors.\"\"\"\n\n\nclass UnsupportedFeatureError(PDDLError):\n \"\"\"Raised when a PDDL construct outside the supported subset is used.\"\"\"\n\n\n@dataclass(frozen=True)\nclass Atom:\n \"\"\"A (possibly lifted) predicate application, e.g. ``(on ?x ?y)``.\n\n ``args`` holds terms as raw strings: variables keep their leading ``?``\n while constants/objects are stored verbatim.\n \"\"\"\n\n predicate: str\n args: tuple = ()\n\n def __str__(self) -> str:\n if not self.args:\n return f\"({self.predicate})\"\n return f\"({self.predicate} {' '.join(self.args)})\"\n\n\n@dataclass(frozen=True)\nclass Literal:\n \"\"\"A positive or negative atom used in preconditions and goals.\"\"\"\n\n atom: Atom\n positive: bool = True\n\n\n@dataclass(frozen=True)\nclass EqualityConstraint:\n \"\"\"An ``(= a b)`` (or its negation) constraint over terms.\"\"\"\n\n left: str\n right: str\n positive: bool = True\n\n\n# --- numeric expressions -----------------------------------------------------\n\n\n@dataclass(frozen=True)\nclass Number:\n \"\"\"A numeric literal.\"\"\"\n\n value: float\n\n\n@dataclass(frozen=True)\nclass FluentRef:\n \"\"\"A reference to a numeric fluent, e.g. ``(fuel ?truck)``.\"\"\"\n\n name: str\n args: tuple = ()\n\n def __str__(self) -> str:\n if not self.args:\n return f\"({self.name})\"\n return f\"({self.name} {' '.join(self.args)})\"\n\n\n@dataclass(frozen=True)\nclass Arithmetic:\n \"\"\"A binary arithmetic expression (``+``, ``-``, ``*``, ``/``).\n\n Unary minus is parsed as ``(- 0 x)``.\n \"\"\"\n\n op: str\n left: object\n right: object\n\n\n@dataclass(frozen=True)\nclass Comparison:\n \"\"\"A numeric comparison used in preconditions and goals.\"\"\"\n\n op: str # one of < <= = >= >\n left: object\n right: object\n\n\n# --- condition formulas (negation normal form) -------------------------------\n\n\n@dataclass(frozen=True)\nclass Truth:\n \"\"\"The constant ``true`` \u2014 what an empty ``(and)`` parses to.\"\"\"\n\n value: bool = True\n\n\n@dataclass(frozen=True)\nclass And:\n parts: tuple = ()\n\n\n@dataclass(frozen=True)\nclass Or:\n parts: tuple = ()\n\n\n@dataclass(frozen=True)\nclass Exists:\n params: tuple = () # ((var, type), ...)\n body: object = None\n\n\n@dataclass(frozen=True)\nclass Forall:\n params: tuple = () # ((var, type), ...)\n body: object = None\n\n\n@dataclass\nclass Conjunct:\n \"\"\"One ground DNF disjunct: a conjunction of literals and comparisons.\n\n Produced by the grounder, not by the parser. ``equalities`` are resolved\n during instantiation and never survive into a grounded operator.\n \"\"\"\n\n literals: list = field(default_factory=list)\n equalities: list = field(default_factory=list)\n comparisons: list = field(default_factory=list)\n\n\n# --- effects -----------------------------------------------------------------\n\n\n@dataclass\nclass AddEffect:\n atom: Atom\n\n\n@dataclass\nclass DelEffect:\n atom: Atom\n\n\n@dataclass\nclass IncreaseCostEffect:\n \"\"\"``(increase (total-cost) k)`` \u2014 the classical action-cost shorthand.\"\"\"\n\n amount: float\n\n\n@dataclass\nclass NumericEffect:\n \"\"\"An assignment to a numeric fluent.\n\n ``op`` is one of ``assign``, ``increase``, ``decrease``, ``scale-up`` or\n ``scale-down``.\n \"\"\"\n\n op: str\n target: FluentRef\n value: object # an arithmetic expression\n\n\n@dataclass\nclass ConjunctiveEffect:\n parts: list = field(default_factory=list)\n\n\n@dataclass\nclass ForallEffect:\n params: list # [(variable, type), ...]\n body: object\n\n\n@dataclass\nclass WhenEffect:\n condition: object # a condition formula\n body: object\n\n\n# --- domain / problem --------------------------------------------------------\n\n\n@dataclass\nclass Predicate:\n name: str\n params: list # [(variable, type), ...]\n\n\n@dataclass\nclass Function:\n \"\"\"A declared numeric function (``:functions``).\"\"\"\n\n name: str\n params: list = field(default_factory=list)\n\n\n@dataclass\nclass Action:\n name: str\n parameters: list # [(variable, type), ...]\n precondition: object # a condition formula\n effect: object # one of the *Effect nodes above\n # Set when the action came from a (:durative-action ...) block; the value is\n # its duration, and the compilation is documented in jupyddl.requirements.\n duration: object = None\n\n\n@dataclass\nclass DerivedPredicate:\n \"\"\"A ``(:derived (head ?x) body)`` axiom.\"\"\"\n\n head: Atom\n params: list # [(variable, type), ...]\n body: object # a condition formula\n\n\n@dataclass\nclass Domain:\n name: str\n requirements: list\n types: dict # child type -> parent type (\"object\" if none)\n constants: list # [(name, type), ...]\n predicates: list\n actions: list\n functions: list = field(default_factory=list)\n derived: list = field(default_factory=list)\n object_fluents: list = field(default_factory=list)\n constraints: list = field(default_factory=list)\n\n @property\n def has_durative_actions(self) -> bool:\n return any(action.duration is not None for action in self.actions)\n\n\n@dataclass\nclass Problem:\n name: str\n domain_name: str\n objects: list # [(name, type), ...]\n init: list # [Atom, ...]\n goal: object # a condition formula\n metric_minimize_cost: bool = False\n init_numeric: dict = field(default_factory=dict) # FluentRef -> float\n metric: object = None # (direction, expression) or None\n preferences: list = field(default_factory=list) # [Preference, ...]\n constraints: list = field(default_factory=list) # [Constraint | Preference]\n timed_initials: list = field(default_factory=list) # [TimedInitial, ...]\n init_objects: dict = field(default_factory=dict) # object-fluent assignments\n violation_weights: dict = field(default_factory=dict) # preference -> weight\n\n\n# --- PDDL 3: preferences and trajectory constraints --------------------------\n\n\n@dataclass(frozen=True)\nclass Preference:\n \"\"\"A named soft goal or soft constraint.\n\n ``body`` is either a condition formula (a goal preference) or a\n :class:`Constraint` (a soft trajectory constraint). Violating it is legal;\n the ``:metric`` says what it costs.\n \"\"\"\n\n name: str\n body: object\n\n\n@dataclass(frozen=True)\nclass Constraint:\n \"\"\"One state-trajectory constraint from a ``(:constraints ...)`` block.\n\n ``kind`` is the PDDL modal operator (``always``, ``sometime``, ``at-end``,\n ``at-most-once``, ``sometime-before``, ``sometime-after``) and ``args`` holds\n its operand formulas, already in negation normal form.\n \"\"\"\n\n kind: str\n args: tuple = ()\n\n\n@dataclass(frozen=True)\nclass TimedInitial:\n \"\"\"A ``(at