T4 — Plan with short-horizon search¶
Wire POMDPAdapter to a small random-shooting planner that simulates
candidate action sequences forward through the env and picks the best
by cumulative reward, ending with one planned step from a sampled
initial state. Builds on
T3 — Write an active-pursuer policy; reference
implementation at
examples/planners/receding_horizon.py.
What this is and isn't¶
This is random shooting — sample K action sequences, simulate
each, take the first action of the best. It is not MCTS: there is
no tree, no UCB, no expansion-by-uncertainty. The teaching value is
the API patterns: how to call transition / reward, how to thread
PRNG keys through a horizon loop, how the action vector is laid out,
and how a pure adapter composes with jax.vmap and jax.lax.scan.
For real planning, swap the search loop for mctx
or pomdp-py. The wiring stays
the same.
The POMDP adapter surface¶
from dataclasses import dataclass
import jax
import jax.numpy as jnp
from orbitalgym import (
OrbitalGymEnv,
POMDPAdapter,
Side,
make_pursuit_evasion,
)
cfg = make_pursuit_evasion(seed=0)
env = OrbitalGymEnv(cfg)
adapter = POMDPAdapter(env)
states_dim = adapter.states_dim
action_dim_per_side = adapter.action_dim_per_side
n_g = cfg.n_guards
n_b = cfg.n_bandits
full_action_dim = (n_g + n_b) * action_dim_per_side
POMDPAdapter exposes a POMDPs.jl-shaped
interface over OrbitalGymEnv:
states_dim— flat-state dimensionality. The vector packsStateLayout.flatten(per-side guard/bandit truth), then two scalar tail entries,tandstep, then the game's per-bandit dwell counters. The reference orbit is captured at__init__time as a per-scenario constant, so it doesn't need to live in the flat vector.action_dim_per_side— components ofΔvper vehicle (3 for RTN dynamics, 2 for in-plane).discount() → float— episode-bounded games return1.0. Override by subclassing if your problem needsγ < 1.initialstate(key) → s_flat— sample an initial state and return its flat vector.transition(s_flat, a_flat, key) → s_flat'— apply one macro step:action_repeatenv substeps of the same action, one by default.observation(s, a, s', side) → obs— per-side observation ats'.reward(s, a, s', side) → float— per-side scalar reward.
The action vector layout is guards-first then bandits. For
n_g guards and n_b bandits, each with d = action_dim_per_side
components, the flat action is
with total length (n_g + n_b) * d.
Sample an initial state¶
s0 is a flat (states_dim,) array — the per-side truth, the
two-scalar (t, step) tail, and the dwell counters. There is no
hidden mutable bookkeeping;
every call to transition reads its bookkeeping out of the input
vector and writes the new bookkeeping into the output vector.
Build the planner¶
@dataclass(frozen=True)
class RecedingHorizonRollout:
"""Random-shooting planner over a flat-vector POMDPAdapter.
Sample ``n_candidates`` action sequences of length ``horizon``,
simulate each forward through ``adapter.transition``, score by
cumulative reward to ``controlled_side``, and return the first
action of the best-scoring sequence.
``POMDPAdapter`` is pure, so the entire search compiles to a
single ``jax.vmap(jax.lax.scan)`` call: candidates run in
parallel on whatever device JAX has, and each candidate's
horizon rolls forward inside a scanned step.
"""
adapter: POMDPAdapter
controlled_side: Side
n_candidates: int = 32
horizon: int = 3
dv_scale: float = 0.05
def act(self, s_flat: jax.Array, key: jax.Array) -> jax.Array:
n_g = self.adapter.env.config.n_guards
n_b = self.adapter.env.config.n_bandits
d = self.adapter.action_dim_per_side
action_dim = (n_g + n_b) * d
def score_sequence(seq_key):
keys = jax.random.split(seq_key, self.horizon + 1)
actions = jax.random.normal(keys[0], (self.horizon, action_dim)) * self.dv_scale
def step(carry_state, idx):
a = actions[idx]
s_next = self.adapter.transition(carry_state, a, keys[idx + 1])
r = self.adapter.reward(carry_state, a, s_next, self.controlled_side)
return s_next, r
_, rewards = jax.lax.scan(step, s_flat, jnp.arange(self.horizon))
return rewards.sum(), actions[0]
seq_keys = jax.random.split(key, self.n_candidates)
scores, first_actions = jax.vmap(score_sequence)(seq_keys)
best = jnp.argmax(scores)
return first_actions[best]
The planner is a single jax.vmap(jax.lax.scan):
- Outer
vmapoverKcandidates — each candidate gets its own PRNG sub-key. JAX runs them in parallel on whatever device is available. - Inner
lax.scanoverHhorizon steps —transitionadvances the flat state,rewardscores it for the controlled side, and the scan accumulates the per-step rewards. argmaxover the per-candidate cumulative rewards picks the best sequence; the planner returns its first action.
Because POMDPAdapter is pure, all of this compiles to a single
JIT call. No Python loops, no per-candidate state snapshots — the
horizon-rollout primitive is a JAX function from (seq_key) →
(score, first_action) and vmap does the parallelism.
Plan one step¶
planner = RecedingHorizonRollout(
adapter=adapter,
controlled_side=Side.BANDIT,
n_candidates=8,
horizon=3,
)
key_plan, key_step = jax.random.split(jax.random.PRNGKey(1))
a_planner = planner.act(s0, key_plan)
s1 = adapter.transition(s0, a_planner, key_step)
r_bandit = adapter.reward(s0, a_planner, s1, Side.BANDIT)
a_planner has shape (full_action_dim,) = ((n_g + n_b) * d,) —
the same flat layout the adapter consumes. Feeding it back into
adapter.transition advances one real env step from s0, and
adapter.reward(s0, a_planner, s1, Side.BANDIT) scores it from the
bandit's perspective.
When to override discount¶
POMDPAdapter.discount() returns 1.0 by default because the bundled
games (make_pursuit_evasion, make_lady_bandit_guard,
make_sun_blocking) are episode-bounded — terminal conditions and
max_horizon_s cap the trajectory, so undiscounted cumulative reward is
well-defined. Pass POMDPAdapter(env, discount=γ) when you wire the
adapter into an unbounded MDP or want to emphasise near-term reward;
discount() then reports γ ** action_repeat, the per-macro-step
discount. The planner above ignores discount; a serious search loop
should multiply it into the inner-loop accumulator.
Going further¶
mctx— DeepMind's JAX-native MCTS library. Plugs into a pure dynamics function;POMDPAdapter.transitionis exactly that, so it slots in asrecurrent_fndirectly.pomdp-py— Python POMDPs framework with a similar interface toPOMDPAdapter. The flat vector here is intentionally compatible with that ecosystem.
Next¶
→ T5 — Run on GPU / MPS moves the same rollouts off CPU and onto an accelerator.