Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,24 @@ plans because it replays them through the same model. They are now pinned by
so LM-cut broke ties differently in every process: gripper with `astar`/`lmcut`
expanded 1725, 1758 or 1760 nodes and returned different plans. Ids are now
assigned in sorted order, so a benchmark reproduces.
- **`iddfs`, `idastar` and `ehc` ignored their budget**, so `--time-limit 0.5`
on blocksworld12 ran for minutes. `beam` polled once per layer and `bnb` kept
evaluating LM-cut on every sibling after the budget fired; both now stop at
once. A new contract test runs every planner where the budget is the only
thing that stops it.
- **A depth cap was reported as "no plan exists"** (`iddfs`, `bnb`), and a
`bnb` plan found under one as optimal. Hitting the cap now sets `truncated`,
as does a search deeper than Python's recursion limit.
- **`bfs` and `iddfs` were listed as optimal.** They minimise plan *length*;
on errands they return cost 16 where 10 is optimal. They are no longer in
`OPTIMAL_PLANNERS`, and the workbench no longer badges them.
- **The live dashboard** said "no plan found" when the budget stopped the
search, smeared every repaint on a narrow terminal (lines were never clipped
to its width), and logged ten times too often when not on a terminal.
- `idastar`, `ehc`, `hc` and `beam` without a heuristic raise a clear
`ValueError` instead of a `TypeError` from deep inside the search;
`MultiObserver` accepts positional arguments like the base class; the
`SearchObserver` docstring no longer claims hooks are called defensively.

## [2.4.0]

Expand Down
46 changes: 41 additions & 5 deletions jupyddl/live.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
from __future__ import annotations

import math
import os
import re
import shutil
import sys
import time
Expand Down Expand Up @@ -103,6 +105,7 @@ def __init__(
self._best_h = math.inf
self._start = time.perf_counter()
self._last_paint = 0.0
self._last_line = 0.0
self._lines = 0
self._title = "jupyddl"
self._latest = None
Expand All @@ -115,10 +118,13 @@ def _c(self, key: str, text: str) -> str:
return f"{_ANSI[key]}{text}{_ANSI['reset']}"

def _width(self) -> int:
# The stream's own terminal, not stdout's: the dashboard usually writes
# to stderr, and the two can differ.
try:
return max(48, min(shutil.get_terminal_size().columns - 2, 100))
except Exception: # pragma: no cover - very unusual terminals
return 72
columns = os.get_terminal_size(self.stream.fileno()).columns
except (AttributeError, OSError, ValueError):
columns = shutil.get_terminal_size().columns
return max(20, min(columns - 2, 100))

def _clear(self) -> None:
if self._lines and self.interactive:
Expand Down Expand Up @@ -161,6 +167,9 @@ def _render(self, final: bool = False) -> None:
self._c("muted", f" {_fmt(rate)} nodes/s · {elapsed:.2f}s")
+ (self._c("muted", f" · {self._bounds} bounds") if self._bounds else ""),
]
# A line wider than the terminal wraps, and the cursor-up that erases
# the previous frame then climbs too few rows, smearing every repaint.
lines = [_clip(line, width + 1) for line in lines]
self._clear()
self.stream.write("\n".join(lines) + "\n")
self.stream.flush()
Expand All @@ -174,10 +183,11 @@ def _maybe_paint(self) -> None:
if self.interactive:
self._render()
else:
# Non-tty: a single appended line, much less often.
if now - self._start > 1 and int(now) % 2 == 0:
# Non-tty: a single appended line, at most every two seconds.
if now - self._start > 1 and now - self._last_line >= 2.0:
event = self._latest
if event is not None:
self._last_line = now
self.stream.write(
f" ... {_fmt(event.expanded)} expanded, "
f"{_fmt(event.generated)} generated, "
Expand Down Expand Up @@ -238,13 +248,39 @@ def on_finish(self, result) -> None:
f" ✔ solved · cost {result.cost} · "
f"{result.plan_length} actions · {elapsed:.2f}s",
)
elif getattr(result, "truncated", False):
# Not "no plan": the budget ran out before the search finished.
verdict = self._c(
"orange", f" ⏸ stopped by the budget, no plan yet · {elapsed:.2f}s"
)
else:
verdict = self._c("orange", f" ✘ no plan found · {elapsed:.2f}s")
self.stream.write(verdict + "\n")
self.stream.flush()
self._lines = 0


_ESCAPE = re.compile(r"\x1b\[[0-9;]*m")


def _clip(line: str, width: int) -> str:
"""Cut ``line`` to ``width`` visible characters, keeping colour codes intact."""
out, visible, pos = [], 0, 0
for match in _ESCAPE.finditer(line):
text = line[pos : match.start()]
if visible + len(text) > width:
out.append(text[: width - visible])
return "".join(out) + "\x1b[0m"
out.append(text)
visible += len(text)
out.append(match.group())
pos = match.end()
tail = line[pos:]
if visible + len(tail) > width:
return "".join(out) + tail[: width - visible] + ("\x1b[0m" if out else "")
return "".join(out) + tail


def _num(value) -> str:
if value is None:
return "–"
Expand Down
4 changes: 3 additions & 1 deletion jupyddl/search/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@
"awastar": AnytimeWeightedAStar,
}

# Planners that are cost-optimal given an admissible heuristic (or none).
# Planners that are cost-optimal given an admissible heuristic (or none), and
# whose run completed: a result with ``truncated`` set proves nothing. ``bfs``
# and ``iddfs`` are not here -- they minimise plan *length*.
OPTIMAL_PLANNERS = tuple(
name for name, factory in PLANNERS.items() if getattr(factory, "optimal", False)
)
Expand Down
11 changes: 10 additions & 1 deletion jupyddl/search/anytime.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ def descend(node, on_path):
observer.on_goal(node.state, node.g, node.depth, stats)
return
if node.depth >= self.max_depth:
# Pruned by the depth cap, not by the bound: the incumbent is
# no longer a proof of optimality, and "no plan" is not one of
# unsolvability.
stats.truncated = True
return
stats.expanded += 1
if observer is not None:
Expand All @@ -85,6 +89,8 @@ def descend(node, on_path):
action="" if node.action is None else node.action.name,
)
for op in task.applicable_operators(node.state):
if budget is not None and budget.exceeded(stats):
return # do not evaluate the siblings left behind
succ = task.apply(op, node.state)
stats.generated += 1
if succ in on_path:
Expand All @@ -100,7 +106,10 @@ def descend(node, on_path):
descend(child, on_path)
on_path.discard(succ)

descend(make_root(start), {start})
try:
descend(make_root(start), {start})
except RecursionError:
stats.truncated = True # deeper than Python's stack allows

stats.runtime = time.perf_counter() - started
solved = best_plan is not None
Expand Down
21 changes: 20 additions & 1 deletion jupyddl/search/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@
from .result import SearchResult, SearchStats


class BudgetExhausted(Exception):
"""Unwinds a recursive search once ``budget.exceeded`` has said stop.

``Budget.exceeded`` has already set ``stats.truncated`` by then, so the
planner that catches this reports "no plan yet" (or its best plan so far)
rather than "no plan exists".
"""


class Planner:
"""Base class for all planners."""

Expand All @@ -35,6 +44,14 @@ def search(
"""
raise NotImplementedError

def _require(self, heuristic) -> None:
"""Fail clearly, not deep inside the search, when a heuristic is missing."""
if heuristic is None:
raise ValueError(
f"planner '{self.name}' needs a heuristic; pass one "
"('blind' if you want no guidance)"
)


def heuristic_name(heuristic) -> str:
"""Best-effort display name for a heuristic (``""`` when there is none)."""
Expand All @@ -52,7 +69,9 @@ def best_first(
prefers the node nearer the goal among equal ``f`` -- in which case
observers are given its first element as ``f``.
``observer`` is an optional :class:`~jupyddl.trace.SearchObserver` that is
notified as the search progresses (see :mod:`jupyddl.trace`).
notified as the search progresses (see :mod:`jupyddl.trace`). It does not
call ``observer.on_start``: the planner wrapping it does, so the trace
records that planner's name rather than the engine's.
"""
stats = SearchStats()
start = time.perf_counter()
Expand Down
13 changes: 10 additions & 3 deletions jupyddl/search/enforced_hill_climbing.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class EnforcedHillClimbing(Planner):
requires_heuristic = True

def search(self, task, heuristic=None, observer=None, budget=None) -> SearchResult:
self._require(heuristic)
stats = SearchStats()
start = time.perf_counter()
if observer is not None:
Expand All @@ -39,9 +40,11 @@ def search(self, task, heuristic=None, observer=None, budget=None) -> SearchResu
observer.on_bound(float(current_h), plateau, stats)
plateau += 1
state, ops, path_cost, new_h = self._probe(
task, current, current_h, heuristic, stats, observer
task, current, current_h, heuristic, stats, observer, budget
)
if state is None: # no strictly-improving state reachable
# No strictly-improving state reachable -- or the budget ran out,
# in which case stats.truncated says so.
if state is None:
stats.runtime = time.perf_counter() - start
result = SearchResult(False, None, None, stats)
if observer is not None:
Expand All @@ -58,11 +61,15 @@ def search(self, task, heuristic=None, observer=None, budget=None) -> SearchResu
observer.on_finish(result)
return result

def _probe(self, task, start, target_h, heuristic, stats, observer=None):
def _probe(
self, task, start, target_h, heuristic, stats, observer=None, budget=None
):
"""BFS from ``start`` for a state with ``h < target_h`` (or the goal)."""
visited = {start}
queue = deque([(start, [], 0)])
while queue:
if budget is not None and budget.exceeded(stats):
break
state, ops, cost = queue.popleft()
stats.expanded += 1
if observer is not None:
Expand Down
23 changes: 17 additions & 6 deletions jupyddl/search/ida_star.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import math
import time

from .base import Planner, heuristic_name
from .base import BudgetExhausted, Planner, heuristic_name
from .node import extract_plan, make_child, make_root
from .result import SearchResult, SearchStats

Expand All @@ -22,6 +22,7 @@ class IDAStarSearch(Planner):
optimal = True

def search(self, task, heuristic=None, observer=None, budget=None) -> SearchResult:
self._require(heuristic)
stats = SearchStats()
start = time.perf_counter()
if observer is not None:
Expand All @@ -44,9 +45,15 @@ def h_of(state):
if observer is not None:
observer.on_bound(float(threshold), iteration, stats)
iteration += 1
found, nxt = self._dfs(
task, root, threshold, h_of, stats, {start_state}, observer
)
try:
found, nxt = self._dfs(
task, root, threshold, h_of, stats, {start_state}, observer, budget
)
except BudgetExhausted:
break
except RecursionError:
stats.truncated = True # deeper than Python's stack allows
break
if found is not None:
stats.runtime = time.perf_counter() - start
result = SearchResult(True, extract_plan(found), found.g, stats)
Expand All @@ -61,13 +68,17 @@ def h_of(state):
observer.on_finish(result)
return result

def _dfs(self, task, node, threshold, h_of, stats, on_path, observer=None):
def _dfs(
self, task, node, threshold, h_of, stats, on_path, observer=None, budget=None
):
h = h_of(node.state)
f = node.g + h
if f > threshold:
return None, f
if task.goal_reached(node.state):
return node, threshold
if budget is not None and budget.exceeded(stats):
raise BudgetExhausted
stats.expanded += 1
if observer is not None:
observer.on_expand(
Expand Down Expand Up @@ -99,7 +110,7 @@ def _dfs(self, task, node, threshold, h_of, stats, on_path, observer=None):
)
on_path.add(succ)
found, nxt = self._dfs(
task, child, threshold, h_of, stats, on_path, observer
task, child, threshold, h_of, stats, on_path, observer, budget
)
on_path.discard(succ)
if found is not None:
Expand Down
10 changes: 8 additions & 2 deletions jupyddl/search/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ def __init__(self, max_sideways: int = 30, restarts: int = 3, seed: int = 0):
self.seed = seed

def search(self, task, heuristic=None, observer=None, budget=None) -> SearchResult:
self._require(heuristic)
stats = SearchStats()
started = time.perf_counter()
if observer is not None:
Expand Down Expand Up @@ -132,6 +133,7 @@ def __init__(self, width: int = 20):
self.width = max(1, int(width))

def search(self, task, heuristic=None, observer=None, budget=None) -> SearchResult:
self._require(heuristic)
stats = SearchStats()
started = time.perf_counter()
if observer is not None:
Expand All @@ -150,11 +152,13 @@ def search(self, task, heuristic=None, observer=None, budget=None) -> SearchResu
seen = {start}
depth = 0
while layer:
if budget is not None and budget.exceeded(stats):
break
depth += 1
scored = []
for node in layer:
# Polled per node, not per layer: a layer is up to `width`
# expansions with a heuristic call per successor.
if budget is not None and budget.exceeded(stats):
break
stats.expanded += 1
if observer is not None:
observer.on_expand(
Expand Down Expand Up @@ -187,6 +191,8 @@ def search(self, task, heuristic=None, observer=None, budget=None) -> SearchResu
continue
scored.append((value, child))

if stats.truncated:
break
scored.sort(key=lambda entry: entry[0])
layer = [child for _, child in scored[: self.width]]
if observer is not None and layer:
Expand Down
Loading
Loading