Skip to content

Commit ad7fd82

Browse files
committed
Make PartialOrderPlanner sound and deterministic via backtracking (#1273)
The planner selected the achiever for each open precondition from an unordered set and committed greedily with no backtracking, so results depended on PYTHONHASHSEED: across runs it printed 'Probably Wrong' / "Couldn't find a solution", and even when it 'succeeded' it could emit an invalid plan (a causal link whose required ordering add_const had silently dropped as cyclic). Rewrite execute() as a backtracking search with deterministic selection: - find_open_precondition now iterates in sorted order and returns the most-constrained precondition (fewest achievers), or signals a dead end when some open precondition has no achiever; - _search backtracks over both the achieving-action choice and the threat resolution (promotion vs demotion), and rejects a causal link whose act0 < act1 ordering cannot be enforced (would create a cycle); - iterative deepening on the number of actions yields short, valid plans and keeps the search fast. All seven standard problems now solve deterministically with valid, near-optimal plans (verified by simulating each plan to its goal across several PYTHONHASHSEED values). Add a regression test that executes the produced plan for every standard problem and asserts it reaches the goal, and refresh the notebook outputs.
1 parent 954b387 commit ad7fd82

3 files changed

Lines changed: 587 additions & 382 deletions

File tree

‎planning.py‎

Lines changed: 144 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1512,6 +1512,11 @@ class PartialOrderPlanner:
15121512

15131513
def __init__(self, planning_problem):
15141514
self.tries = 1
1515+
# safety bounds for the backtracking search in execute(): the maximum
1516+
# number of actions a plan may contain (iterative-deepening target) and
1517+
# the maximum number of node expansions per deepening level
1518+
self._max_plan_actions = 12
1519+
self._max_expansions = 20000
15151520
self.planning_problem = planning_problem
15161521
self.causal_links = []
15171522
self.start = Action('Start', [], self.planning_problem.initial)
@@ -1527,39 +1532,33 @@ def __init__(self, planning_problem):
15271532
self.expanded_actions = planning_problem.expand_actions()
15281533

15291534
def find_open_precondition(self):
1530-
"""Find open precondition with the least number of possible actions"""
1531-
1535+
"""
1536+
Find the open precondition with the least number of achieving actions
1537+
(a most-constrained-variable heuristic). Returns the triple
1538+
(precondition, action_that_needs_it, [achieving_actions]). Iteration is
1539+
ordered deterministically so the search does not depend on set/hash
1540+
ordering. Returns (None, None, None) when some open precondition has no
1541+
achiever at all, which is a dead end for the current partial plan.
1542+
"""
1543+
possible_actions = list(self.actions) + self.expanded_actions
15321544
number_of_ways = dict()
15331545
actions_for_precondition = dict()
1534-
for element in self.agenda:
1535-
open_precondition = element[0]
1536-
possible_actions = list(self.actions) + self.expanded_actions
1537-
for action in possible_actions:
1538-
for effect in action.effect:
1539-
if effect == open_precondition:
1540-
if open_precondition in number_of_ways:
1541-
number_of_ways[open_precondition] += 1
1542-
actions_for_precondition[open_precondition].append(action)
1543-
else:
1544-
number_of_ways[open_precondition] = 1
1545-
actions_for_precondition[open_precondition] = [action]
1546-
1547-
number = sorted(number_of_ways, key=number_of_ways.__getitem__)
1548-
1549-
for k, v in number_of_ways.items():
1550-
if v == 0:
1546+
for open_precondition, act in sorted(self.agenda, key=str):
1547+
if open_precondition in number_of_ways:
1548+
continue
1549+
achievers = [action for action in possible_actions
1550+
if any(effect == open_precondition for effect in action.effect)]
1551+
if not achievers:
15511552
return None, None, None
1553+
number_of_ways[open_precondition] = len(achievers)
1554+
actions_for_precondition[open_precondition] = achievers
15521555

1553-
act1 = None
1554-
for element in self.agenda:
1555-
if element[0] == number[0]:
1556-
act1 = element[1]
1557-
break
1556+
if not number_of_ways:
1557+
return None, None, None
15581558

1559-
if number[0] in self.expanded_actions:
1560-
self.expanded_actions.remove(number[0])
1561-
1562-
return number[0], act1, actions_for_precondition[number[0]]
1559+
chosen = min(number_of_ways, key=lambda p: (number_of_ways[p], str(p)))
1560+
act1 = next(act for precond, act in sorted(self.agenda, key=str) if precond == chosen)
1561+
return chosen, act1, actions_for_precondition[chosen]
15631562

15641563
def find_action_for_precondition(self, oprec):
15651564
"""Find action for a given precondition"""
@@ -1739,61 +1738,131 @@ def display_plan(self):
17391738
print(list(reversed(list(self.toposort(self.convert(self.constraints))))))
17401739

17411740
def execute(self, display=True):
1742-
"""Execute the algorithm"""
1743-
1744-
step = 1
1745-
while len(self.agenda) > 0:
1746-
step += 1
1747-
# select <G, act1> from Agenda
1748-
try:
1749-
G, act1, possible_actions = self.find_open_precondition()
1750-
except IndexError:
1751-
print('Probably Wrong')
1752-
break
1741+
"""
1742+
Execute the algorithm with backtracking, using iterative deepening on the
1743+
number of actions in the plan. The original greedy version committed to
1744+
the first achiever it happened to iterate over and could not recover when
1745+
that action's own preconditions turned out to be unsatisfiable, so it
1746+
depended on hash ordering and often printed 'Probably Wrong' / "Couldn't
1747+
find a solution". Backtracking over both action choices and threat
1748+
resolution (promotion vs demotion), together with the deterministic
1749+
selection in find_open_precondition and a smallest-plan-first deepening
1750+
bound, makes the planner solve the standard problems reproducibly and
1751+
return a short, valid plan.
1752+
"""
1753+
pristine = self._snapshot()
1754+
for limit in range(1, self._max_plan_actions + 1):
1755+
self._restore(pristine)
1756+
if self._search([self._max_expansions], limit):
1757+
if display:
1758+
self.display_plan()
1759+
else:
1760+
return self.constraints, self.causal_links
1761+
return
1762+
print("Couldn't find a solution")
1763+
if not display:
1764+
return None, None
1765+
1766+
def _reachable(self, source, target):
1767+
"""True if target is forced to come after source by the ordering constraints"""
1768+
1769+
stack, seen = [source], set()
1770+
while stack:
1771+
node = stack.pop()
1772+
if node == target:
1773+
return True
1774+
if node in seen:
1775+
continue
1776+
seen.add(node)
1777+
stack.extend(b for a, b in self.constraints if a == node)
1778+
return False
17531779

1754-
act0 = possible_actions[0]
1755-
# remove <G, act1> from Agenda
1756-
self.agenda.remove((G, act1))
1780+
def _open_threat(self):
1781+
"""
1782+
Return an (action, causal_link) threat that is not yet resolved by the
1783+
ordering constraints, or None if every causal link is protected. A
1784+
causal link (a0, p, a1) is threatened by an action whose effect negates p
1785+
unless the action is already ordered before a0 (promotion) or after a1
1786+
(demotion).
1787+
"""
1788+
for a0, p, a1 in self.causal_links:
1789+
for action in self.actions:
1790+
if action == a0 or action == a1:
1791+
continue
1792+
if any(self.is_a_threat(p, effect) for effect in action.effect):
1793+
if not (self._reachable(action, a0) or self._reachable(a1, action)):
1794+
return action, (a0, p, a1)
1795+
return None
17571796

1758-
# For actions with variable number of arguments, use least commitment principle
1759-
# act0_temp, bindings = self.find_action_for_precondition(G)
1760-
# act0 = self.generate_action_object(act0_temp, bindings)
1797+
def _snapshot(self):
1798+
return set(self.actions), set(self.constraints), list(self.causal_links), set(self.agenda)
17611799

1762-
# Actions = Actions U {act0}
1763-
self.actions.add(act0)
1800+
def _restore(self, snapshot):
1801+
self.actions, self.constraints, self.causal_links, self.agenda = (
1802+
set(snapshot[0]), set(snapshot[1]), list(snapshot[2]), set(snapshot[3]))
17641803

1765-
# Constraints = add_const(start < act0, Constraints)
1766-
self.constraints = self.add_const((self.start, act0), self.constraints)
1804+
def _search(self, budget, limit):
1805+
"""
1806+
Recursively complete the partial plan, backtracking on failure. Three
1807+
kinds of choice points are explored: which action satisfies an open
1808+
precondition, how each threat is resolved (promotion vs demotion), and -
1809+
bounded by 'limit' - whether to introduce a new action at all. Returns
1810+
True and leaves the solution in self.* on success.
1811+
"""
1812+
if budget[0] <= 0:
1813+
return False
1814+
budget[0] -= 1
1815+
1816+
# first, resolve any outstanding threat to a causal link (choice point)
1817+
threat = self._open_threat()
1818+
if threat is not None:
1819+
action, (a0, p, a1) = threat
1820+
snapshot = self._snapshot()
1821+
for ordering in ((action, a0), (a1, action)): # promotion, then demotion
1822+
new_constraints = self.add_const(ordering, self.constraints)
1823+
if ordering in new_constraints: # ordering was consistent (acyclic and allowed)
1824+
self.constraints = new_constraints
1825+
if self._search(budget, limit):
1826+
return True
1827+
self._restore(snapshot)
1828+
return False
17671829

1768-
# for each CL E CausalLinks do
1769-
# Constraints = protect(CL, act0, Constraints)
1770-
for causal_link in self.causal_links:
1771-
self.constraints = self.protect(causal_link, act0, self.constraints)
1830+
# no open threats: a plan with an empty agenda is a complete solution
1831+
if not self.agenda:
1832+
return True
17721833

1773-
# Agenda = Agenda U {<P, act0>: P is a precondition of act0}
1774-
for precondition in act0.precond:
1775-
self.agenda.add((precondition, act0))
1834+
# select <G, act1> from the agenda (most-constrained precondition first)
1835+
G, act1, possible_actions = self.find_open_precondition()
1836+
if G is None: # an open precondition has no achiever -> dead end
1837+
return False
17761838

1777-
# Constraints = add_const(act0 < act1, Constraints)
1839+
# number of actions already introduced, excluding the dummy Start/Finish
1840+
introduced = len(self.actions) - 2
1841+
snapshot = self._snapshot()
1842+
# try each achiever deterministically, reusing existing actions first
1843+
for act0 in sorted(set(possible_actions), key=lambda a: (a not in self.actions, str(a))):
1844+
is_new = act0 not in self.actions
1845+
if is_new and introduced >= limit: # deepening bound on plan size
1846+
continue
1847+
self.agenda.discard((G, act1))
1848+
self.actions.add(act0)
1849+
self.constraints = self.add_const((self.start, act0), self.constraints)
17781850
self.constraints = self.add_const((act0, act1), self.constraints)
1779-
1780-
# CausalLinks U {<act0, G, act1>}
1781-
if (act0, G, act1) not in self.causal_links:
1782-
self.causal_links.append((act0, G, act1))
1783-
1784-
# for each A E Actions do
1785-
# Constraints = protect(<act0, G, act1>, A, Constraints)
1786-
for action in self.actions:
1787-
self.constraints = self.protect((act0, G, act1), action, self.constraints)
1788-
1789-
if step > 200:
1790-
print("Couldn't find a solution")
1791-
return None, None
1792-
1793-
if display:
1794-
self.display_plan()
1795-
else:
1796-
return self.constraints, self.causal_links
1851+
# the causal link act0 --G--> act1 requires act0 strictly before act1
1852+
# (and after start); add_const drops an ordering that would create a
1853+
# cycle, so reject the choice when the required ordering is not enforced
1854+
if ((act0 == act1 or self._reachable(act0, act1)) and
1855+
(act0 == self.start or self._reachable(self.start, act0))):
1856+
if (act0, G, act1) not in self.causal_links:
1857+
self.causal_links.append((act0, G, act1))
1858+
if is_new: # a freshly introduced action contributes its own preconditions
1859+
for precondition in act0.precond:
1860+
self.agenda.add((precondition, act0))
1861+
if self._search(budget, limit):
1862+
return True
1863+
# undo and try the next achiever
1864+
self._restore(snapshot)
1865+
return False
17971866

17981867

17991868
def spare_tire_graph_plan():

0 commit comments

Comments
 (0)