Hierarchical, Symbolic, Language, and Multi-Agent Planning › Worked example: options with explicit initiation and failure

Michael BrenndoerferJuly 12, 20266 min read
World Models HandbookAll chapters
Part 7 of 12Planning and Agency
Chapter 42Hierarchical, Symbolic, Language, and Multi-Agent Planning › Worked example: options with explicit initiation and failure
Reading section 6 of 13 · Worked example: options with explicit initiation and failure · 6 minSection 6 of 13 · 6 min

Book contents

Section 6 of 13 · 6 min read

Hierarchical, Symbolic, Language, and Multi-Agent Planning › Worked example: options with explicit initiation and failureOne section · Sections in this chapter6 / 13 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

The gridworld below has a start, a key, a locked door, and a goal. The state includes position, key possession, and door status. Navigation, unlocking, and goal-reaching are separate options with explicit initiation predicates and structured results. The example is deterministic and illustrative; it compares decision granularity, not runtime or sample efficiency.

In[10]:
Code
from collections import deque
from dataclasses import dataclass

import numpy as np

GRID = np.array(
    [
        [0, 0, 0, 1, 0, 0],
        [0, 1, 0, 1, 0, 1],
        [0, 1, 2, 1, 0, 0],
        [0, 0, 0, 0, 3, 0],
        [1, 1, 1, 1, 0, 0],
        [0, 0, 0, 0, 4, 0],
    ]
)

START = (0, 0)
KEY = tuple(map(int, np.argwhere(GRID == 2)[0]))
DOOR = tuple(map(int, np.argwhere(GRID == 3)[0]))
DOOR_APPROACH = (DOOR[0], DOOR[1] - 1)
GOAL = tuple(map(int, np.argwhere(GRID == 4)[0]))
MOVES = [(-1, 0), (1, 0), (0, -1), (0, 1)]


@dataclass(frozen=True)
class EnvState:
    cell: tuple
    has_key: bool
    door_open: bool


@dataclass(frozen=True)
class OptionResult:
    state: EnvState
    cells: tuple
    steps: int
    succeeded: bool
    reason: str


def step(state, action):
    """Apply a movement or unlock action and return the next environment state."""
    if action == "unlock":
        if state.cell != DOOR_APPROACH or not state.has_key:
            return state
        return EnvState(state.cell, True, True)

    dr, dc = action
    nr, nc = state.cell[0] + dr, state.cell[1] + dc
    if not (0 <= nr < GRID.shape[0] and 0 <= nc < GRID.shape[1]):
        return state
    if GRID[nr, nc] == 1 or ((nr, nc) == DOOR and not state.door_open):
        return state
    cell = (int(nr), int(nc))
    return EnvState(cell, state.has_key or cell == KEY, state.door_open)

The unlock action changes door_open; merely holding the key no longer changes the door dynamics implicitly. Navigation policies are shortest-path tables computed for either a closed or open door.

In[11]:
Code
def bfs_policy(target, door_open):
    """Map each reachable cell to one shortest-path movement toward target."""
    frontier = deque([target])
    distance = {target: 0}
    action_from = {}
    while frontier:
        cell = frontier.popleft()
        for action in MOVES:
            nr, nc = cell[0] - action[0], cell[1] - action[1]
            previous = (nr, nc)
            if not (0 <= nr < GRID.shape[0] and 0 <= nc < GRID.shape[1]):
                continue
            if GRID[nr, nc] == 1 or (previous == DOOR and not door_open):
                continue
            if previous in distance:
                continue
            distance[previous] = distance[cell] + 1
            action_from[previous] = action
            frontier.append(previous)
    return action_from


NAV_POLICIES = {
    "go_to_key": (KEY, bfs_policy(KEY, door_open=False)),
    "go_to_door": (DOOR_APPROACH, bfs_policy(DOOR_APPROACH, door_open=False)),
    "go_to_goal": (GOAL, bfs_policy(GOAL, door_open=True)),
}


def option_is_initiable(name, state):
    if name == "go_to_key":
        return not state.has_key and state.cell in NAV_POLICIES[name][1]
    if name == "go_to_door":
        return (
            state.has_key
            and not state.door_open
            and state.cell in NAV_POLICIES[name][1]
        )
    if name == "unlock_door":
        return (
            state.has_key
            and not state.door_open
            and state.cell == DOOR_APPROACH
        )
    if name == "go_to_goal":
        return (
            state.has_key
            and state.door_open
            and state.cell in NAV_POLICIES[name][1]
        )
    return False


def run_option(name, state, step_cap=64):
    if not option_is_initiable(name, state):
        return OptionResult(
            state, (state.cell,), 0, False, "initiation condition failed"
        )
    if name == "unlock_door":
        next_state = step(state, "unlock")
        return OptionResult(
            next_state,
            (state.cell,),
            1,
            next_state.door_open,
            "terminated" if next_state.door_open else "unlock failed",
        )

    target, policy = NAV_POLICIES[name]
    cells = [state.cell]
    for used in range(1, step_cap + 1):
        if state.cell == target:
            return OptionResult(
                state, tuple(cells), used - 1, True, "terminated"
            )
        action = policy.get(state.cell)
        if action is None:
            return OptionResult(
                state, tuple(cells), used - 1, False, "no policy action"
            )
        next_state = step(state, action)
        if next_state.cell == state.cell:
            return OptionResult(
                state, tuple(cells), used, False, "blocked transition"
            )
        state = next_state
        cells.append(state.cell)
        if state.cell == target:
            return OptionResult(state, tuple(cells), used, True, "terminated")
    return OptionResult(
        state, tuple(cells), step_cap, False, "step cap exhausted"
    )


def execute_option_sequence(names, initial):
    state = initial
    trajectory = [state.cell]
    total_steps = 0
    log = []
    for name in names:
        result = run_option(name, state)
        log.append((name, result.succeeded, result.reason, result.steps))
        state = result.state
        trajectory.extend(result.cells[1:])
        total_steps += result.steps
        if not result.succeeded:
            return state, trajectory, total_steps, log
    return state, trajectory, total_steps, log

First execute a hand-specified sequence. This demonstrates option semantics; it is not yet a high-level planner.

Out[12]:
Console
Manual option sequence succeeded: True
Primitive timesteps: 10
Option calls: 4
Observed mean option duration: 2.50

The ratio is exactly the sample mean duration of the four option calls in this run. It estimates E[τ]\mathbb{E}[\tau] only across representative repeated executions. A larger task increases compression only if its selected options span longer useful behaviors; horizon alone does not force the ratio upward. The injected-wall check above confirms that a failed option returns its reached state, visited cells, and attempted step count; it restores the map before the successful trajectory is plotted.

Out[13]:
Visualization
Gridworld path from a labeled start to key, door, and goal, with the legend placed outside the path.
Successful trajectory from a manually specified four-option sequence on the restored map. Explicit initiation checks gate each option; the key, door approach, unlock transition, and goal use one consistent environment state. Nine movements plus one stationary unlock action are grouped into four high-level calls.