Probabilistic Robotics
Chapter 21PART VIPlanning and Acting under UncertaintyDifficulty: IntermediateEstimated reading time: 55 min

Decision Making I: MDPs and Value Iteration

A plan is a line through space; a policy is an answer for every state. This chapter derives the Bellman equation, proves value iteration converges, and shows why noise makes the optimal route longer.

One way to cope with the resulting uncertainty is to generate a policy for action selection defined for all states that the robot might encounter.
Sebastian Thrun, Wolfram Burgard, and Dieter FoxProbabilistic Robotics, Chapter 15

In this chapter

Chapter 20 ended with a beautiful path and a robot sliding off it. That is not a bug in the planner. A path is a function of time — do this, then this, then this — and the moment the wheels deliver something other than what was commanded, the robot is at a state the path has nothing to say about. Every chapter since Chapter 9 has been about how reliably the wheels fail to deliver.

This chapter makes the move the rest of Part VI pivots on: stop computing a path and start computing a policy, a function of state that answers "what should I do?" everywhere, so that drifting off is not a failure mode but a Tuesday. The framework is the Markov decision process, the algorithm is value iteration, and the object it produces — the value function — is Chapter 20's potential field done properly: a scalar field that cannot have spurious local minima, because it is defined as expected cost-to-go rather than sculpted out of geometry.

The chapter closes on the question Chapter 22 exists to answer. A policy is a function of the state. What if the robot does not know the state?

The plan that slides off the floor

Here is the experiment, run twice on the same world, with the same solver and the same random numbers. On the left, Rusty commits to the optimal action sequence and replays it open-loop. On the right, Rusty consults the same solver's answer at every step.

At zero slip the two pictures are identical, and that identity is the whole reason deterministic planning ever worked: when execution is perfect, the sequence of actions along the optimal path is the optimal policy restricted to that path. Turn the slip up and the pictures diverge immediately, and they diverge in a specific, diagnostic way.

The plan does not fail gradually. It fails at the first divergence and then accumulates. Once the robot is one cell off the planned trajectory, the remaining actions in the sequence were computed for somebody else's position. Step seventeen of a plan is a claim about where you will be at step seventeen, and after a few unlucky draws it is a claim about a place you have never been.

The policy is unbothered. It never notices the "error", because the notion of error requires a reference trajectory and there isn't one. It reads the current cell and answers. The realized path is longer than the nominal one — noise costs something, and the cost is real — but the arrival rate barely moves. Run that world four thousand times with matched seeds and the split is brutal:

slip ss0.000.000.050.050.100.100.150.150.300.30
open-loop plan reaches the dock100%100\%21.1%21.1\%3.7%3.7\%0.9%0.9\%0.0%0.0\%
policy reaches the dock100%100\%100%100\%100%100\%100%100\%99.8%99.8\%
policy's mean steps (nominal 16)16.016.017.917.920.320.323.523.543.543.5

A five percent chance of veering — which the next section prices at about 13.5°13.5° of heading uncertainty, an ordinary afternoon for a localizer — already sinks the plan four times out of five. The policy pays for the same noise in steps rather than in failures, and steps are a currency you can budget.

The two objects are different types. This is worth being pedantic about, because the type is the lesson:

plan:{0,1,,T}Upolicy:XU\text{plan} : \{0, 1, \ldots, T\} \to \mathcal{U} \qquad\qquad \text{policy} : \mathcal{X} \to \mathcal{U}

A plan is indexed by time. A policy is indexed by state. Everything else in this chapter follows from taking that difference seriously.

The obvious objection is that replanning fixes this: run the planner again from wherever you actually are. It does, and it is what most deployed systems do. But notice what replanning converges to as the replanning rate goes up — an answer for every state you might visit, computed on demand. A policy is what you get when you precompute all of them, and it is worth building once so you can see what the on-demand version is approximating. (When the state space is too big to precompute, you go back to on-demand: that is Chapter 23.)

Building intuition

Paint a reward, get a behavior

The following widget is the chapter in one object. A gridworld is laid over the Apartment floorplan; you paint payoff onto cells; value iteration re-converges live and the arrow field is read off whatever the value function currently is. Nothing is precomputed, and Rusty walks the current policy — including while it is still wrong.

Three things repay a minute of watching, and each one is a theorem later in the chapter.

Value propagates backward from the payoff, roughly a ring per sweep. Immediately after you paint a goal, only its neighbours know about it. That is why value iteration feels like a wave, and why the sweep count scales with the diameter of the map rather than its area. Look closely and the wave is lopsided — it runs further in one direction per sweep than the other — because the backups are done in place, so a cell late in the sweep already sees the cells updated before it. Flip the synchronous sweeps toggle and the lopsidedness disappears, along with some of the speed.

The arrows straighten out long before the numbers do. Watch the residual readout: the arrow field stops changing while ΔV\lVert \Delta V \rVert_\infty is still large. That is not a rendering artifact, and it is not luck either — the loss bound explains why a value function nowhere near converged still yields a usable policy, and the measurement below shows this scene's policy going exactly optimal after 45 of its 72 sweeps. argmax only cares about the ordering of the QQ-values, and orderings settle first.

Slip bends the field away from the hazard. At s=0s = 0 the arrows run right past the spill cell — why not, you never veer. Push ss toward 0.30.3 and a visible berth opens up. Nobody programmed a safety margin; the margin is what the expectation computes when some of the futures in it involve veering into the thing you are hugging. Measured on the default scene, the optimal policy's mean journey grows from 37.037.0 steps at s=0s = 0 to 45.545.5 at s=0.2s = 0.2 to 56.956.9 at s=0.4s = 0.4. The detour margin is the noise, priced.

Everything in that widget is the real algorithm. The sweeps are sweepInPlace from lib/decision/mdp.ts, the arrows are greedyPolicy, and the robot's steps are drawn from the same sparse transition rows the solver plans against — the TypeScript twin of the Rust in Implementation in Rust. If the two ever disagreed, the numerical self-checks would say so.

The mathematics

Notation

Notation used in this chapter
SymbolMeaning
X, U\mathcal{X},\ \mathcal{U}Finite state and action sets. Here: free grid cells, and the eight compass moves.
p(xx,u)p(x' \mid x, u)Transition model — the same object the Bayes filter predicts with, now indexed by a decision.
r(x,u)r(x, u)Expected immediate payoff of taking u in x. Negative for cost.
γ(0,1]\gamma \in (0, 1]Discount factor. γ < 1 for discounted problems; γ = 1 only for stochastic shortest paths.
π:XU\pi : \mathcal{X} \to \mathcal{U}A deterministic stationary policy — an action for every state, not a sequence.
RTR_TExpected cumulative discounted payoff over horizon T.
Vπ(x), V(x)V^{\pi}(x),\ V^{*}(x)Value of following π from x; value of acting optimally from x.
Q(x,u)Q^{*}(x, u)Value of doing u once, then acting optimally. Chapter 22 reuses these directly.
T\mathcal{T}The Bellman backup operator, (TV)(x) = max_u [ r + γ Σ p V ].
ssSlip: probability of veering into each of the two neighbouring directions. Intended direction: 1 − 2s.

Thrun et al. write the payoff as c(x)c(x'), collected on entering a state, and put the discount outside the integral. We fold the expected entry payoff into r(x,u)r(x, u), which is the textbook form and changes not a single number:

r(x,u)  =  cost(u)  +  xp(xx,u)c(x)r(x, u) \;=\; -\,\text{cost}(u) \;+\; \sum_{x'} p(x' \mid x, u)\, c(x')

The book's grid compiler does exactly this, which is why goal payoff shows up in the neighbours of the goal rather than in the goal cell itself.

The five pieces, and where each one came from

A Markov decision process is the tuple

(X,  U,  p(xx,u),  r(x,u),  γ)\big(\, \mathcal{X},\; \mathcal{U},\; \htmlClass{term-prediction}{p(x' \mid x, u)},\; \htmlClass{term-measurement}{r(x, u)},\; \gamma \,\big)

with three standing assumptions, each of which is load-bearing and each of which is false somewhere.

  1. Markov. p(xx,u)p(x' \mid x, u) depends on the past only through xx. This is Chapter 5's assumption, and it is what licenses a policy to be a function of the current state alone. Drop it and the policy needs history.
  2. Full observability. The robot knows xx exactly at decision time. This is the assumption this chapter buys its tractability with, and it is the one Chapter 22 pays back.
  3. Stationarity. pp and rr do not depend on tt. Together with an infinite horizon, this is what makes the optimal policy itself stationary — one lookup table, not one per timestep.

Only the first two pieces are new here. X\mathcal{X} is Chapter 13's occupancy grid with the walls inflated by the robot radius, exactly the configuration-space trick of Chapter 20. p(xx,u)p(x' \mid x, u) is Chapter 9's velocity motion model, discretized. The reward is the only genuinely new modelling decision, and it is where all the honesty lives: an MDP does not tell you what to want, it tells you what to do given what you want.

Where the slip parameter comes from

Gridworld papers announce a slip probability. We are going to earn ours, because the book's promise is that every probability has a pedigree.

The robot commands "move to the neighbouring cell in direction dd". It drives for Δt=/v\Delta t = \ell / v seconds, where \ell is the cell size. The cell it lands in is decided by the bearing of its net displacement: inside the ±π/8\pm \pi/8 sector around dd and it landed on the intended neighbour; outside, and it landed on one of the two flanking cells. So

2s  =  Pr ⁣[  atan2(Δy,Δx)>π8  ]2s \;=\; \Pr\!\left[\; \left| \operatorname{atan2}(\Delta y, \Delta x) \right| > \frac{\pi}{8} \;\right]

where (Δx,Δy)(\Delta x, \Delta y) is drawn by the Chapter 9 sampler: perturb the commanded (v,ω)(v, \omega) by v^=v+εα1v2\hat{v} = v + \varepsilon_{\alpha_1 v^2} and ω^=ω+εα3v2\hat{\omega} = \omega + \varepsilon_{\alpha_3 v^2}, then integrate the arc for Δt\Delta t, starting from a heading that is itself uncertain by σθ\sigma_\theta — because the robot aims using the heading it believes it has.

DerivationIntegrating the velocity model over one cell transit

Start the transit at pose (0,0,θ0)(0, 0, \theta_0) with θ0N(0,σθ2)\theta_0 \sim \Normal(0, \sigma_\theta^2), the heading error the localizer has left on the table. Thrun et al., Table 5.3 gives the endpoint of an arc with perturbed controls (v^,ω^)(\hat v, \hat\omega) after Δt\Delta t:

Δx=v^ω^sinθ0+v^ω^sin(θ0+ω^Δt),Δy=v^ω^cosθ0v^ω^cos(θ0+ω^Δt)\Delta x = -\frac{\hat v}{\hat\omega}\sin\theta_0 + \frac{\hat v}{\hat\omega}\sin(\theta_0 + \hat\omega \Delta t), \qquad \Delta y = \frac{\hat v}{\hat\omega}\cos\theta_0 - \frac{\hat v}{\hat\omega}\cos(\theta_0 + \hat\omega \Delta t)

Step 1 — why bearing and not lateral offset. A tempting alternative is to ask whether the lateral displacement exceeds half a cell. It essentially never does: with =0.3\ell = 0.3 m you would need θ0>30°|\theta_0| > 30°. But the grid does not ask "did you drift half a cell sideways", it asks "which cell are you in", and over a single short transit that question is answered by direction. Binning on bearing is the discretization that matches the state space.

Step 2 — the bearing has a closed form, and it is exact. Apply the sum-to-product identities to both components:

Δx=2v^ω^sin ⁣(ω^Δt2)cos ⁣(θ0+ω^Δt2),Δy=2v^ω^sin ⁣(ω^Δt2)sin ⁣(θ0+ω^Δt2)\Delta x = 2\frac{\hat v}{\hat\omega}\,\sin\!\Big(\tfrac{\hat\omega \Delta t}{2}\Big)\cos\!\Big(\theta_0 + \tfrac{\hat\omega \Delta t}{2}\Big), \qquad \Delta y = 2\frac{\hat v}{\hat\omega}\,\sin\!\Big(\tfrac{\hat\omega \Delta t}{2}\Big)\sin\!\Big(\theta_0 + \tfrac{\hat\omega \Delta t}{2}\Big)

The two share a common scalar factor, and that factor is positive whenever v^>0\hat v > 0 — the sign of ω^\hat\omega cancels between 1/ω^1/\hat\omega and sin(ω^Δt/2)\sin(\hat\omega\Delta t/2). So

atan2(Δy,Δx)  =  θ0+12ω^Δt\operatorname{atan2}(\Delta y, \Delta x) \;=\; \theta_0 + \tfrac{1}{2}\hat\omega\,\Delta t

exactly, not to leading order: the chord of a circular arc bisects the turn. And v^\hat v has dropped out entirely — a robot that drives too fast overshoots but does not veer.

Step 3 — collect the variance. With θ0N(0,σθ2)\theta_0 \sim \Normal(0, \sigma_\theta^2) and ω^N(0,α3v2)\hat\omega \sim \Normal(0, \alpha_3 v^2) independent, the bearing is Gaussian, and substituting Δt=/v\Delta t = \ell / v makes the second term's dependence on speed vanish too:

Var ⁣[12ω^Δt]=14α3v22v2=α324  s  =  Φ ⁣(π/8σθ2+α32/4)  \Var\!\left[\tfrac{1}{2}\hat\omega \Delta t\right] = \tfrac{1}{4}\,\alpha_3 v^2 \cdot \frac{\ell^2}{v^2} = \frac{\alpha_3 \ell^2}{4} \qquad\Longrightarrow\qquad \boxed{\; s \;=\; \Phi\!\left( -\frac{\pi/8}{\sqrt{\sigma_\theta^2 + \alpha_3 \ell^2 / 4}} \right) \;}

With α3=0.05\alpha_3 = 0.05 and =0.3\ell = 0.3 m the turn-rate term contributes 0.0340.034 rad — a shade under two degrees — no matter how fast Rusty drives. The library's slipFromVelocityModel samples the model rather than assuming this, and self-check 11 pins the sampler to the closed form.

Step 4 — read the number. Sampled with 40,00040{,}000 seeded draws and full noise, against the formula:

σθ\sigma_\theta5°10°10°15°15°20°20°
closed form1.3×1051.3 \times 10^{-5}0.01360.01360.06840.06840.13140.1314
sampled0.00050.00050.01390.01390.06840.06840.13150.1315

\blacksquare

The one place the two rows disagree is instructive. At σθ=5°\sigma_\theta = 5° the sampler reports 0.00050.0005 where the formula says 1.3×1051.3 \times 10^{-5} — a factor of forty. The excess is not noise; it is Pr[v^<0]=Φ(1/α1)=7.8×104\Pr[\hat v < 0] = \Phi(-1/\sqrt{\alpha_1}) = 7.8 \times 10^{-4}, the fraction of draws in which the Gaussian velocity model of Chapter 9 hands back a negative speed and Rusty reverses into the cell behind him. Below about six degrees of heading uncertainty, the dominant source of "slip" in this model is an artifact of describing a non-negative quantity with a Gaussian. Every noise model has a regime where its tails stop being a harmless approximation, and this is where the velocity model's is.

That table is the most important thing in the section, and it says something the gridworld literature usually hides. A well-localized robot barely slips. At five degrees of heading uncertainty, honest discretized slip is 1.3×1051.3 \times 10^{-5}. The slip parameter is not a property of the floor; it is overwhelmingly a property of the localizer. Discretized slip is a state-estimation error wearing a motion-model costume — which is a broad hint that the honest version of this chapter's problem is Chapter 22's.

The widgets let you set ss up to 0.40.4 anyway. Read those settings as "a robot with a bad localizer, a wet floor, or 0.3 m cells that are too coarse for its dynamics" — all three are real, and the middle one is why the parameter is worth a slider.

Return, and why γ\gamma buys convergence

Fix a policy π\pi and run it forever. The return is the discounted sum of payoffs,

RT  =  E ⁣[τ=0Tγτrτ],Vπ(x)  =  E ⁣[τ=0γτr(xτ,π(xτ))  |  x0=x]R_T \;=\; \E\!\left[\, \sum_{\tau=0}^{T} \gamma^{\tau}\, r_{\tau} \,\right], \qquad \htmlClass{term-posterior}{V^{\pi}(x)} \;=\; \E\!\left[\, \sum_{\tau=0}^{\infty} \gamma^{\tau}\, r(x_\tau, \pi(x_\tau)) \;\middle|\; x_0 = x \right]

Discounting does two jobs, and it is worth separating them because only one is mathematical.

The mathematical job: if rrmax|r| \le r_{\max} then Vπrmax/(1γ)|V^\pi| \le r_{\max}/(1-\gamma), so an infinite sum of infinitely many terms is a finite number and the whole theory has something to be about. Without it, "reach the dock eventually" and "reach the dock in nine steps" both score -\infty and argmax\arg\max has nothing to chew on.

The modelling job: γ\gamma is a statement about how far ahead the robot cares. The effective horizon is 1/(1γ)1/(1-\gamma) steps, in the sense that payoff beyond it is discounted below 1/e1/e:

γ\gamma0.80.80.90.90.950.950.980.980.990.99
effective horizon 1/(1γ)1/(1-\gamma)55101020205050100100

For a 0.3 m grid, γ=0.98\gamma = 0.98 means the robot can see about 15 metres of consequence. Set γ=0.9\gamma = 0.9 and the charging dock becomes invisible from the far bedroom — the value function goes flat out there, the arrows go arbitrary, and the robot wanders. That failure is not a bug in the solver; it is the solver correctly reporting that under your stated preferences, the dock is not worth walking to. You can watch it happen with the Policy Painter's γ\gamma slider.

The Bellman optimality equation

Define the optimal value function V(x)=maxπVπ(x)V^*(x) = \max_\pi V^\pi(x) and the state–action value

Q(x,u)  =  r(x,u)  +  γxp(xx,u)V(x)\htmlClass{term-posterior}{Q^{*}(x, u)} \;=\; \htmlClass{term-measurement}{r(x, u)} \;+\; \htmlClass{term-prior}{\gamma \sum_{x'} p(x' \mid x, u)\, V^{*}(x')}

— the value of committing to uu once and behaving optimally forever after. Then the whole of dynamic programming is the observation that VV^* is the pointwise max of QQ^*:

  V(x)  =  maxuU[r(x,u)  +  γxp(xx,u)V(x)]  \boxed{\; \htmlClass{term-posterior}{V^{*}(x)} \;=\; \max_{u \in \mathcal{U}} \left[\, \htmlClass{term-measurement}{r(x, u)} \;+\; \htmlClass{term-prior}{\gamma \sum_{x'} p(x' \mid x, u)\, V^{*}(x')} \,\right] \;}

Read it as a sentence: the best you can do from here is the best over first moves of (what that move pays now) plus (discounted, averaged over where it might land you, the best you can do from there). The colors match the widget below: green is the immediate payoff, blue is the discounted future, purple is the answer.

That widget exists because the equation above looks like linear algebra and is not. A backup is clerical work: read a handful of neighbours, weight them by how likely you are to reach them, scale by γ\gamma, add the payoff, keep the biggest. Step it a few times and watch the value wave crawl backwards from the goal and bend through the doorway — the same wave Chapter 20's wave-front planner produced, which is not a coincidence and is proved below.

DerivationDeriving the Bellman equation from the finite-horizon recursion

Thrun et al. build this by induction on the horizon, and so will we, because the induction is where the optimal-substructure argument actually lives.

Step 1 — horizon one. With exactly one action left, there is no future to trade against:

V1(x)  =  maxur(x,u)V_1(x) \;=\; \max_u\, r(x, u)

Step 2 — horizon two, by conditioning on the first move. Take an action uu, collect r(x,u)r(x,u), land in xx' with probability p(xx,u)p(x' \mid x, u), and then you have a one-step problem left. The best you can do from xx' with one step left is V1(x)V_1(x') by definition, so

V2(x)  =  maxu[r(x,u)+γxp(xx,u)V1(x)]V_2(x) \;=\; \max_u \left[\, r(x,u) + \gamma \sum_{x'} p(x' \mid x, u)\, V_1(x') \,\right]

Step 3 — why the tail is allowed to be optimal. This is the step that deserves suspicion. The claim is that the tail of an optimal 22-step plan is an optimal 11-step plan. It is true here for a specific reason: the payoff decomposes additively across time, the discount factors out, and the future dynamics depend on the past only through xx' (Markov). So the total is r(x,u)+γE[tail]r(x,u) + \gamma\,\E[\text{tail}], and the tail term is maximized independently of the choice of uu — for each possible xx' separately. Break any of those three properties and the induction fails; risk-sensitive objectives, for instance, are not additively decomposable and genuinely do not admit this argument.

Step 4 — induct.

VT(x)  =  maxu[r(x,u)+γxp(xx,u)VT1(x)]V_{T}(x) \;=\; \max_u \left[\, r(x,u) + \gamma \sum_{x'} p(x' \mid x, u)\, V_{T-1}(x') \,\right]

Step 5 — take the limit. With rrmax|r| \le r_{\max}, the horizon-TT and horizon-(T+1)(T{+}1) values differ by at most the tail of a geometric series, VT+1VTγTrmax\lVert V_{T+1} - V_T \rVert_\infty \le \gamma^{T} r_{\max}, which vanishes for γ<1\gamma < 1. So VTV_T is Cauchy in the sup norm and converges to some VV_\infty; passing to the limit inside the finite max and finite sum gives V=TVV_\infty = \mathcal{T} V_\infty. The next derivation shows that fixed point is unique, so V=VV_\infty = V^*. \blacksquare

Value iteration converges, and here is the stopping rule

Write the Bellman equation as an operator on value functions:

(TV)(x)  =  maxu[r(x,u)+γxp(xx,u)V(x)](\mathcal{T}V)(x) \;=\; \max_u \left[\, r(x,u) + \gamma \sum_{x'} p(x' \mid x, u)\, V(x') \,\right]

Value iteration is Vk+1=TVkV_{k+1} = \mathcal{T} V_k, starting from V0=0V_0 = 0. The theorem that makes it an algorithm rather than a hope is that T\mathcal{T} is a contraction.

DerivationT is a γ-contraction in the sup norm, and the stopping bound that follows

Step 1 — the max inequality. For any two families of reals {au}\{a_u\}, {bu}\{b_u\} over a finite index set,

maxuaumaxubu    maxuaubu\left| \max_u a_u - \max_u b_u \right| \;\le\; \max_u \left| a_u - b_u \right|

Proof: let u=argmaxuauu^\star = \arg\max_u a_u. Then maxuaumaxubuaubumaxuaubu\max_u a_u - \max_u b_u \le a_{u^\star} - b_{u^\star} \le \max_u |a_u - b_u|. Swap the roles of aa and bb for the other direction. (Exercise 1 asks you to exhibit vectors where it is tight.)

Step 2 — apply it to the backup. Fix xx and set au=r(x,u)+γxpU(x)a_u = r(x,u) + \gamma\sum_{x'} p\,U(x'), bu=r(x,u)+γxpV(x)b_u = r(x,u) + \gamma\sum_{x'} p\,V(x'). The rewards cancel:

(TU)(x)(TV)(x)    γmaxuxp(xx,u)(U(x)V(x))\left| (\mathcal{T}U)(x) - (\mathcal{T}V)(x) \right| \;\le\; \gamma \max_u \left| \sum_{x'} p(x' \mid x, u)\big(U(x') - V(x')\big) \right|

Step 3 — push the expectation through. The transition row is a probability distribution, so it is an averaging operator and cannot amplify:

xp(xx,u)(U(x)V(x))    xp(xx,u)UV  =  UV\left| \sum_{x'} p(x' \mid x, u)\big(U(x') - V(x')\big) \right| \;\le\; \sum_{x'} p(x' \mid x, u)\,\lVert U - V \rVert_\infty \;=\; \lVert U - V \rVert_\infty

Step 4 — take the sup over xx. TUTVγUV\lVert \mathcal{T}U - \mathcal{T}V \rVert_\infty \le \gamma \lVert U - V \rVert_\infty. Since γ<1\gamma < 1 and the space of bounded value functions with the sup norm is complete, Banach's fixed-point theorem gives a unique fixed point VV^* and geometric convergence VkVγkV0V\lVert V_k - V^* \rVert_\infty \le \gamma^k \lVert V_0 - V^* \rVert_\infty from any start.

Step 5 — the stopping rule. You cannot measure VkV\lVert V_k - V^* \rVert_\infty; you can measure the change one sweep made. Chain them with the triangle inequality:

VkV    jkVj+1Vj    jkγjk+1VkVk1  =  γ1γVkVk1\lVert V_k - V^* \rVert_\infty \;\le\; \sum_{j \ge k} \lVert V_{j+1} - V_j \rVert_\infty \;\le\; \sum_{j \ge k} \gamma^{\,j-k+1} \lVert V_k - V_{k-1} \rVert_\infty \;=\; \frac{\gamma}{1-\gamma} \lVert V_k - V_{k-1} \rVert_\infty

So to guarantee VkVϵ\lVert V_k - V^* \rVert_\infty \le \epsilon, stop when a sweep moves the value function by less than ϵ(1γ)/γ\epsilon(1-\gamma)/\gamma. That is the entire content of stoppingThreshold(gamma, eps) in the library, and it is why a request for ϵ=106\epsilon = 10^{-6} at γ=0.98\gamma = 0.98 actually iterates until sweeps move things by 2×1082 \times 10^{-8}: at γ\gamma near one, the residual you can see is a wild underestimate of the error you have. \blacksquare

The factor γ/(1γ)\gamma/(1-\gamma) is the single most common way to be wrong about a value function. At γ=0.99\gamma = 0.99 it is 9999: a sweep that changes nothing by more than 0.010.01 may still leave you a full unit of reward away from the truth. Reporting "converged" because the residual looked small is how you get a policy that is confidently suboptimal in the one region you cared about.

A half-converged value function still gives a good policy

Greedy extraction is the reason value iteration is useful at all:

πV(x)  =  argmaxu[r(x,u)+γxp(xx,u)V(x)]\htmlClass{term-posterior}{\pi_V(x)} \;=\; \arg\max_u \left[\, \htmlClass{term-measurement}{r(x, u)} + \htmlClass{term-prior}{\gamma \sum_{x'} p(x' \mid x, u)\, V(x')} \,\right]
DerivationGreedy on V* is optimal; greedy on a nearby V is nearly optimal

Part A — exactness at the fixed point. Let π=πV\pi^* = \pi_{V^*} and let Tπ\mathcal{T}_{\pi} be the linear backup that follows π\pi without a max: (TπV)(x)=r(x,π(x))+γxp(xx,π(x))V(x)(\mathcal{T}_\pi V)(x) = r(x, \pi(x)) + \gamma \sum_{x'} p(x' \mid x, \pi(x)) V(x'). By the definition of argmax\arg\max, TπV=TV=V\mathcal{T}_{\pi^*} V^* = \mathcal{T} V^* = V^*, so VV^* is a fixed point of Tπ\mathcal{T}_{\pi^*}. But Tπ\mathcal{T}_{\pi^*} is also a γ\gamma-contraction (Steps 3–4 above never used the max), so its fixed point is unique, and its fixed point is by definition VπV^{\pi^*}. Hence Vπ=VV^{\pi^*} = V^*: greedy is not merely good, it is exact.

Part B — the loss bound. Let ϵ=VV\epsilon = \lVert V - V^* \rVert_\infty and π=πV\pi = \pi_V. Three inequalities chained:

VπVVπTπV(i)+TπVV(ii)\lVert V^{\pi} - V^{*} \rVert_\infty \le \underbrace{\lVert V^{\pi} - \mathcal{T}_{\pi} V \rVert_\infty}_{\text{(i)}} + \underbrace{\lVert \mathcal{T}_{\pi} V - V^{*} \rVert_\infty}_{\text{(ii)}}

For (ii): TπV=TV\mathcal{T}_\pi V = \mathcal{T} V because π\pi is greedy for VV; and TVV=TVTVγϵ\lVert \mathcal{T}V - V^* \rVert_\infty = \lVert \mathcal{T}V - \mathcal{T}V^* \rVert_\infty \le \gamma\epsilon. For (i): Vπ=TπVπV^\pi = \mathcal{T}_\pi V^\pi, so VπTπVγVπVγ(VπV+ϵ)\lVert V^\pi - \mathcal{T}_\pi V \rVert_\infty \le \gamma \lVert V^\pi - V \rVert_\infty \le \gamma(\lVert V^\pi - V^* \rVert_\infty + \epsilon). Substituting and solving for VπV\lVert V^\pi - V^* \rVert_\infty:

VπV    2γϵ1γ\lVert V^{\pi} - V^{*} \rVert_\infty \;\le\; \frac{2\gamma\,\epsilon}{1-\gamma}

\blacksquare

Two readings of that bound, and they point in opposite directions.

Pessimistic: the amplification factor 2γ/(1γ)2\gamma/(1-\gamma) is 9898 at γ=0.98\gamma = 0.98. A value function good to 0.10.1 certifies only a policy within 9.89.8 of optimal — nearly vacuous.

Optimistic, and what actually happens: the bound is worst-case over adversarial MDPs. In a gridworld, argmax\arg\max depends only on the order of the QQ-values at each state, and orderings stabilize long before magnitudes do. Measured on the Apartment scene, the greedy policy becomes exactly optimal — identical to π\pi^* in all 925 free cells — after 45 of the 72 sweeps value iteration eventually takes. The residual at that moment is 2.072.07, so the theorem certifies only VπV101\lVert V^\pi - V^* \rVert_\infty \le 101, on a problem whose optimal value at the start is 7.97.9. The bound is not wrong; it is simply not the thing that happens.

That gap is what real-time dynamic programming (Barto, Bradtke and Singh, 1995) is built to exploit: you may act on a value function you have no right to trust, as long as you keep backing it up along the states you actually visit. It is also why the Policy Painter can run the robot while the wave is still crossing the map.

Policy iteration: solve, improve, repeat

Value iteration nudges every state a little on every sweep. Policy iteration takes the other extreme: pick a policy, evaluate it exactly, then improve it everywhere at once.

The evaluation step is where the linear algebra finally shows up. Fix π\pi; there is no max any more, so the Bellman equation for VπV^\pi is a linear system:

Vπ  =  rπ+γPπVπ(IγPπ)Vπ  =  rπV^{\pi} \;=\; r_{\pi} + \gamma P_{\pi} V^{\pi} \qquad\Longleftrightarrow\qquad \big(I - \gamma P_{\pi}\big) V^{\pi} \;=\; r_{\pi}

with PπP_\pi the X×X|\mathcal{X}| \times |\mathcal{X}| row-stochastic matrix [Pπ]xx=p(xx,π(x))[P_\pi]_{x x'} = p(x' \mid x, \pi(x)) — sparse, with at most three nonzeros per row in our gridworld. That is the same shape of solve as the sparse Cholesky in Chapter 15, and the Rust implementation hands it to the same crate.

DerivationPolicy iteration improves monotonically and terminates

Step 1 — the improvement is not worse. Let π=πVπ\pi' = \pi_{V^\pi} be greedy with respect to VπV^\pi. By construction TπVπ=TVπTπVπ=Vπ\mathcal{T}_{\pi'} V^\pi = \mathcal{T} V^\pi \ge \mathcal{T}_{\pi} V^\pi = V^\pi, pointwise.

Step 2 — monotone operators propagate that. Tπ\mathcal{T}_{\pi'} is monotone: if UWU \ge W pointwise then TπUTπW\mathcal{T}_{\pi'} U \ge \mathcal{T}_{\pi'} W, because it only adds a fixed vector and averages with nonnegative weights. Applying it repeatedly to TπVπVπ\mathcal{T}_{\pi'}V^\pi \ge V^\pi gives TπkVπVπ\mathcal{T}_{\pi'}^{\,k} V^\pi \ge V^\pi for every kk, and the left side converges to VπV^{\pi'}. Hence VπVπV^{\pi'} \ge V^{\pi} pointwise. (This is the policy improvement theorem, and notice it is the monotonicity, not the contraction, doing the work.)

Step 3 — strictness and termination. If π\pi' and π\pi have the same value function, then Vπ=TVπV^\pi = \mathcal{T}V^\pi, so Vπ=VV^\pi = V^* and π\pi is optimal. Otherwise the improvement is strict at some state. There are finitely many deterministic stationary policies — UX|\mathcal{U}|^{|\mathcal{X}|} of them — and the sequence of values is strictly increasing, so no policy repeats and the loop terminates.

Step 4 — what happens in practice. The bound is astronomical and irrelevant. On the four-cell hallway below, policy iteration finishes in one improvement. On the 927-state Apartment with γ=0.98\gamma = 0.98 it takes 23, the first of which fixes 178 states and the last of which fixes 3. The rule of thumb — iterations grow like log\log of the state count, not like the state count — is observed everywhere and proved nowhere useful.

Step 5 — the interpolation. You do not have to solve the linear system exactly. Doing mm linear backups instead is modified policy iteration: m=1m = 1 is value iteration, m=m = \infty is Howard's policy iteration, and the sweet spot for large sparse problems is usually m20m \approx 20. \blacksquare

Stochastic shortest paths: what navigation actually is

Discounting is a strange thing to want from a delivery robot. Nobody prefers a package delivered now over the same package delivered in a minute by a factor of 0.98600.98^{60}; we just want it delivered, quickly, and the discount was a mathematical convenience. The formulation that says what we mean is the stochastic shortest path (SSP):

  • γ=1\gamma = 1 — no discounting;
  • one or more absorbing goal states with zero payoff;
  • r(x,u)=1r(x, u) = -1 per step (or cost(u)-\text{cost}(u)), so every action strictly hurts.

Then V(x)-V^*(x) is literally the minimum expected number of steps to the goal, which is a quantity you can hold in your head. But the contraction argument is gone: γ=1\gamma = 1 makes the modulus 11, and there is no Banach theorem to invoke. What replaces it is a condition on the problem rather than on the discount.

Both conditions are checkable, and both fail in ways you will meet. Condition (i) fails if the inflated map has a walled-off room: the goal is unreachable, V=V^* = -\infty there, and a solver that does not detect it will happily return 1012-10^{12} and an arrow field of noise. Condition (ii) fails if you give the robot a free action — a zero-cost stay — because then "stand still forever" is an improper policy with finite cost 00, and it is optimal. This is not a hypothetical; it is the single most common bug in hand-rolled SSP solvers, and it is why the library's stay action still charges stepCost.

The wave-front planner, unmasked

Now set the slip to zero. Each action has exactly one successor, x=f(x,u)x' = f(x, u), so the sum over xx' collapses to a single term and the SSP Bellman equation becomes

  V(x)  =  minu[cost(u)  +  (V(f(x,u)))]  \boxed{\; -V^{*}(x) \;=\; \min_{u} \Big[\, \text{cost}(u) \;+\; \big(-V^{*}(f(x, u))\big) \Big] \;}

That is the Bellman–Ford relaxation. Sweep it synchronously and you have Bellman–Ford. Sweep it in-place in order of increasing V-V^* — always expanding the unfinished state closest to the goal — and you have Dijkstra. Fill it outward from the goal one ring at a time and you have Chapter 20's wave-front planner, exactly.

So the wave-front planner is not analogous to value iteration; it is value iteration, on a deterministic MDP with unit costs, with a particularly clever sweep order. Everything this chapter adds is what happens when the arrow out of a cell is no longer a promise. And the "potential field with no local minima" that Chapter 20 wanted is now definitional: VV^* has no spurious local optimum because it is not a field somebody sculpted, it is the expected cost-to-go, and a state whose neighbours are all worse than it would be violating its own Bellman equation.

How much detour is noise worth?

We have said twice now that noise makes the optimal route longer. Here is the quantitative version, in the smallest world that can carry it: two corridors from start to goal, one short and flanked by a drop, one long and flanked by walls.

Before touching the widget, commit to a guess. The ledge is 66 cells; the detour is 1616 — nearly three times as long. A veer on the ledge costs a penalty of 66 and dumps the robot back at the start. At what slip ss does the optimal policy abandon the ledge?

Write your number down, then read the derivation.

DerivationClosed-form route values and the critical slip

Let q=12sq = 1 - 2s be the probability that a step goes as commanded, LL the ledge length, MM the detour length, and CC the cliff penalty.

The detour. A veer bumps a wall, costing one step and no progress. The number of attempts needed for one cell of progress is geometric with success probability qq, so its mean is 1/q1/q, and by linearity

Vdetour=MqV_{\text{detour}} = -\frac{M}{q}

Note this is already worse than M-M: even the safe route pays for noise.

The ledge. Let VkV_k be the value with kk cells still to go, so V0=0V_0 = 0, and let W=VLW = V_L be the value at the start. Each step costs 11, and with probability 2s2s the robot goes over, pays CC, and restarts at the start:

Vk=12sCA+qVk1+2sWV_k = \underbrace{-1 - 2sC}_{A} + q\,V_{k-1} + 2s\,W

Write B=A+2sWB = A + 2sW, a constant with respect to kk. Then Vk=B+qVk1V_k = B + qV_{k-1} with V0=0V_0 = 0 unrolls to a geometric sum:

Vk=B1qk1q=B1qk2sV_k = B\,\frac{1 - q^k}{1 - q} = B\,\frac{1 - q^k}{2s}

Now impose self-consistency at k=Lk = L, where VL=WV_L = W. Writing ρ=(1qL)/(2s)\rho = (1-q^L)/(2s):

W=(A+2sW)ρ    W(12sρ)=Aρ    W=AρqLW = (A + 2sW)\rho \;\Longrightarrow\; W(1 - 2s\rho) = A\rho \;\Longrightarrow\; W = \frac{A\rho}{q^{L}}

using 12sρ=1(1qL)=qL1 - 2s\rho = 1 - (1 - q^L) = q^L. Substituting AA and ρ\rho:

Vledge=(1+2sC)(1qL)2sqLV_{\text{ledge}} = -\,\frac{(1 + 2sC)\,\big(1 - q^{L}\big)}{2s\, q^{L}}

Sanity checks. As s0s \to 0, (1qL)/(2s)L(1-q^L)/(2s) \to L and qL1q^L \to 1, so VledgeLV_{\text{ledge}} \to -L: the deterministic answer. And VledgeV_{\text{ledge}} blows up like qLq^{-L}, exponentially in the length of the exposure, while VdetourV_{\text{detour}} degrades only like 1/q1/q. That asymmetry is the whole story.

The critical slip. ss^* is the root of Vledge(s)=Vdetour(s)V_{\text{ledge}}(s) = V_{\text{detour}}(s). It has no closed form, so bisect — on the two formulas, not on a simulation. For L=6L = 6, M=16M = 16, C=6C = 6:

s=0.0671s^{*} = 0.0671

\blacksquare

Six and a bit percent. Most readers guess somewhere between 0.150.15 and 0.30.3, because the detour is so much longer. The values say otherwise:

slip ss0.000.000.020.020.050.050.080.080.150.150.250.25
VπV^{\pi} ledge6.0-6.08.6-8.614.1-14.122.6-22.670.0-70.0504.0-504.0
VπV^{\pi} detour16.0-16.016.7-16.717.8-17.819.0-19.022.9-22.932.0-32.0

The ledge is exponentially fragile and the detour is merely linearly annoying, so they cross early. And notice what s=0.067s^* = 0.067 corresponds to in the units of the previous section: about 15°15° of heading uncertainty. The decision of whether to take the short route through the narrow gap is being made, in effect, by the localizer.

Two honest caveats the widget makes visible. First, long after the policy has switched, individual runs on the ledge still sometimes beat the detour — expectation is a claim about the average and nothing else, and if you need a guarantee about the worst case you want a risk-sensitive objective (Akella et al., 2025), which is a different and harder problem. Second, the mean realized return converges to VV^* slowly and from either side; twenty finished runs tell you almost nothing.

The algorithms

AlgorithmMDP_value_iteration(p, r, γ, ε)CostO(|X|·|U|·b) per sweep for branching factor b; O(log ε / log γ) sweeps
In
transition model, reward, discount, target accuracy
Out
V within ε of V*, and the greedy policy π
  1. for all xx do V^(x)=0\hat V(x) = 0
  2. repeat
  3.     δ=0\delta = 0
  4.     for all xx do
  5.         v=V^(x)v = \hat V(x)
  6.         V^(x)=maxu[r(x,u)+γxp(xx,u)V^(x)]\hat V(x) = \max_u \big[\, r(x,u) + \gamma \sum_{x'} p(x' \mid x, u)\, \hat V(x') \,\big]
  7.         δ=max(δ,V^(x)v)\delta = \max\big(\delta,\, |\hat V(x) - v|\big)
  8.     endfor
  9. until δ<ϵ(1γ)/γ\delta < \epsilon(1-\gamma)/\gamma
  10. π(x)=argmaxu[r(x,u)+γxp(xx,u)V^(x)]\pi(x) = \arg\max_u \big[\, r(x,u) + \gamma \sum_{x'} p(x' \mid x, u)\, \hat V(x') \,\big]
  11. return V^,π\hat V, \pi

This is Thrun et al.'s Table 15.1 with two additions: the stopping rule from the contraction proof (line 9), and the explicit policy extraction (line 10). One subtlety hides in line 6. If V^\hat V is a single array that you write into as you go, later states in the sweep see the updated values of earlier ones — that is the Gauss–Seidel or asynchronous variant, and it is what the library does by default. If you write into a fresh array, it is the synchronous Jacobi variant. Thrun's draft is explicit that this does not affect whether value iteration converges, only how fast. On the Apartment, Gauss–Seidel needs 72 sweeps where Jacobi needs 84 — and if you order the sweep by breadth-first distance from the goal, so that every backup reads neighbours that were updated moments ago, it drops to 33. Same algorithm, same fixed point, less than half the work, purely from the order of a for loop.

Algorithmpolicy_iteration(p, r, γ)Costa sparse |X|×|X| solve per iteration; a handful of iterations
In
transition model, reward, discount
Out
V*, π* — exactly, in finitely many steps
  1. initialize π\pi arbitrarily
  2. repeat
  3.     evaluate: solve (IγPπ)Vπ=rπ(I - \gamma P_{\pi}) V^{\pi} = r_{\pi}
  4.     improve: π(x)=argmaxu[r(x,u)+γxp(xx,u)Vπ(x)]\pi'(x) = \arg\max_u \big[\, r(x,u) + \gamma \sum_{x'} p(x' \mid x, u)\, V^{\pi}(x') \,\big]
  5.     if π=π\pi' = \pi then return Vπ,πV^{\pi}, \pi
  6.     ππ\pi \leftarrow \pi'
  7. forever
Algorithmprioritized_sweeping(p, r, γ, ε, V₀)CostO(log|X|) per backup for the heap; total backups data-dependent
In
the MDP, a target accuracy, and a warm start
Out
V, backed up only where it mattered
  1. HH \leftarrow empty max-heap; for all xx with residual ρ(x)>θ\rho(x) > \theta: push xx with key ρ(x)\rho(x)
  2. while HH nonempty do
  3.     xx \leftarrow pop-max; if its key is stale, continue
  4.     v=V(x)v = V(x); V(x)=(TV)(x)V(x) = (\mathcal{T}V)(x); Δ=V(x)v\Delta = |V(x) - v|
  5.     for each predecessor x~\tilde x of xx do
  6.         κ=γmaxup(xx~,u)Δ\kappa = \gamma \cdot \max_u p(x \mid \tilde x, u) \cdot \Delta
  7.         if κ>θ\kappa > \theta then push x~\tilde x with key κ\kappa
  8. return VV

The priority key on line 6 is an upper bound on how much this backup could possibly move that predecessor — so the heap orders states by potential change without paying for a trial backup. Moore and Atkeson (1993) introduced this for learned models; it is just as useful for known ones.

Prioritized sweeping is not universally faster, and it is worth knowing when it loses. Solving the Apartment MDP cold takes 226,050 prioritized backups against 86,400 for 72 Gauss–Seidel sweeps — 2.6× worse, because when every state needs updating, the heap is pure overhead. Its win is repair: after painting one new hazard cell, prioritized sweeping restores optimality in 4,487 backups where warm-started Gauss–Seidel needs 18 sweeps, or 21,600. That is the regime the Policy Painter lives in, and the regime a robot with a changing map lives in.

A worked example you can check by hand

Four states in a line: AA, BB, CC, and the goal GG, which absorbs. Rusty has two actions.

  • roll — nudge forward. Advances one cell with probability 0.80.8; with probability 0.20.2 the wheels spin and nothing happens. Costs 11.
  • lunge — dump enough current into the motors to guarantee the cell change. Costs 22.

Undiscounted, γ=1\gamma = 1: a stochastic shortest path. Every number below is reproduced by a Rust unit test and by self-check 1 in lib/decision/__checks_ch21__.ts.

The fixed point, by hand

Work backwards from the goal. At CC, roll gives 1+0.8V(G)+0.2V(C)=1+0.2V(C)-1 + 0.8 \cdot V(G) + 0.2 \cdot V(C) = -1 + 0.2\,V(C), and if roll is optimal there, that equals V(C)V(C):

0.8V(C)=1V(C)=1.250.8\,V(C) = -1 \quad\Longrightarrow\quad V(C) = -1.25

which is just 1/0.81/0.8: the expected number of attempts to make one cell of progress. Each cell costs the same, so

V(B)=1+0.8(1.25)+0.2V(B)    V(B)=2.5,V(A)=3.75V(B) = -1 + 0.8(-1.25) + 0.2\,V(B) \;\Longrightarrow\; V(B) = -2.5, \qquad V(A) = -3.75

Is roll really optimal? Check the alternative at each state: lunge from CC scores 2-2 against 1.25-1.25; from BB, 2+(1.25)=3.25-2 + (-1.25) = -3.25 against 2.5-2.5; from AA, 4.5-4.5 against 3.75-3.75. Buying determinism at a price of 22 is a bad deal when the stochastic option costs 1.251.25 in expectation. The max gate has something to do at every state, and it always makes the same choice.

V=(3.75,  2.5,  1.25,  0)\htmlClass{term-posterior}{V^{*} = (-3.75,\; -2.5,\; -1.25,\; 0)}

The first three sweeps

Now run the algorithm synchronously from V0=0V_0 = 0 and watch it get there. Every entry is one line of arithmetic:

sweepV(A)V(A)V(B)V(B)V(C)V(C)V(G)V(G)
0000000000
111-11-11-100
222-22-21.2-1.200
333-32.36-2.361.24-1.2400
443.488-3.4882.464-2.4641.248-1.24800
\infty3.75-3.752.5-2.51.25-1.2500

Check sweep 3 at BB yourself: 1+0.8V2(C)+0.2V2(B)=1+0.8(1.2)+0.2(2)=2.36-1 + 0.8 \cdot V_2(C) + 0.2 \cdot V_2(B) = -1 + 0.8(-1.2) + 0.2(-2) = -2.36. And notice the shape of the convergence. After three sweeps CC is 99% of the way to its answer and AA is only 80%; after four, CC is done to three decimals and AA still is not. Information flows backward from the goal at one cell per synchronous sweep, which is the whole reason sweep order matters.

Value iteration needs 24 sweeps to reach 101210^{-12} here. Policy iteration needs one improvement: evaluate the initial all-roll policy exactly, discover it is already greedy, stop.

Implementation in Rust

The module is crates/ch21_mdp. The design constraint that shapes everything: a gridworld MDP has tens of thousands of states and at most three successors per state-action, so the transition model must be sparse and the inner loop must not allocate.

The model

crates/ch21_mdp/src/mdp.rs
use nalgebra::DVector;

/// One successor and its probability. `p` is f32 because a transition row is
/// read far more often than it is written, and halving the row halves the cache
/// misses in the inner loop — the only place this crate spends time.
#[derive(Clone, Copy, Debug)]
pub struct Transition {
    pub s: u32,
    pub p: f32,
}

/// A sparse row of p(· | x, u). Probabilities sum to 1 up to f32 epsilon.
pub type SparseDist = Vec<Transition>;

/// A finite MDP with a compile-time action count.
///
/// `A` is a const generic because the action set of a gridworld is fixed by its
/// connectivity (4, 8, or 9 with `stay`), and pinning it lets the per-state
/// arrays live inline instead of behind a second indirection.
pub struct Mdp<const A: usize> {
    pub n_states: usize,
    /// Indexed [state][action].
    pub trans: Vec<[SparseDist; A]>,
    /// r(x, u): the expected immediate payoff, with entry payoff folded in.
    pub reward: Vec<[f64; A]>,
    /// γ ∈ (0, 1]. γ = 1 is legal only when `absorbing` is non-empty (SSP).
    pub gamma: f64,
    /// V(x) ≡ 0 here, and the episode stops.
    pub absorbing: Vec<bool>,
    pub action_labels: [&'static str; A],
}

impl<const A: usize> Mdp<A> {
    /// Q(x, u) = r(x, u) + γ Σ_{x'} p(x' | x, u) V(x').
    #[inline]
    pub fn q(&self, v: &[f64], x: usize, u: usize) -> f64 {
        if self.absorbing[x] {
            return 0.0;
        }
        let mut acc = 0.0;
        for t in &self.trans[x][u] {
            acc += f64::from(t.p) * v[t.s as usize];
        }
        self.reward[x][u] + self.gamma * acc
    }

    /// The max gate: (TV)(x) and the action that attains it.
    #[inline]
    pub fn backup(&self, v: &[f64], x: usize) -> (f64, u8) {
        if self.absorbing[x] {
            return (0.0, 0);
        }
        let mut best = f64::NEG_INFINITY;
        let mut arg = 0u8;
        for u in 0..A {
            let q = self.q(v, x, u);
            if q > best {
                best = q;
                arg = u as u8;
            }
        }
        (best, arg)
    }

    /// ‖TV − V‖∞ over non-absorbing states. Zero exactly at the fixed point,
    /// which makes it the only honest convergence certificate.
    pub fn max_residual(&self, v: &DVector<f64>) -> f64 {
        (0..self.n_states)
            .filter(|&x| !self.absorbing[x])
            .map(|x| (self.backup(v.as_slice(), x).0 - v[x]).abs())
            .fold(0.0, f64::max)
    }
}

Value iteration, with the stopping rule that the proof earned

crates/ch21_mdp/src/vi.rs
use nalgebra::DVector;
use crate::mdp::Mdp;

pub struct ViResult {
    pub v: DVector<f64>,
    pub policy: Vec<u8>,
    pub sweeps: usize,
    /// ‖V_k − V_{k−1}‖∞ after each sweep — the widgets' convergence wave.
    pub residuals: Vec<f64>,
    pub converged: bool,
}

/// Stop when one sweep moves V by less than this, and ‖V − V*‖∞ ≤ ε is
/// guaranteed. From ‖V_k − V*‖ ≤ γ/(1−γ)·‖V_k − V_{k−1}‖.
///
/// γ = 1 (a stochastic shortest path) has no such bound: the contraction
/// modulus is 1 in the sup norm, so we fall back to the raw residual and the
/// caller is on the hook for a proper policy existing.
pub fn stopping_threshold(gamma: f64, eps: f64) -> f64 {
    if gamma >= 1.0 { eps } else { eps * (1.0 - gamma) / gamma }
}

/// One in-place Gauss–Seidel sweep. Returns ‖ΔV‖∞.
///
/// In place on purpose: a backup late in the sweep sees the states updated
/// earlier in it, so with a good ordering information crosses the whole map in
/// a single pass instead of one cell per sweep.
pub fn sweep_in_place<const A: usize>(mdp: &Mdp<A>, v: &mut DVector<f64>) -> f64 {
    let mut residual: f64 = 0.0;
    for x in 0..mdp.n_states {
        if mdp.absorbing[x] {
            v[x] = 0.0;
            continue;
        }
        let next = mdp.backup(v.as_slice(), x).0;
        residual = residual.max((next - v[x]).abs());
        v[x] = next;
    }
    residual
}

/// Thrun et al., Table 15.1 — `MDP_value_iteration`.
pub fn value_iteration<const A: usize>(mdp: &Mdp<A>, eps: f64, max_sweeps: usize) -> ViResult {
    let mut v = DVector::zeros(mdp.n_states);
    let threshold = stopping_threshold(mdp.gamma, eps);
    let mut residuals = Vec::new();

    let converged = loop {
        if residuals.len() >= max_sweeps {
            break false;
        }
        let r = sweep_in_place(mdp, &mut v);
        residuals.push(r);
        if r < threshold {
            break true;
        }
    };

    ViResult { policy: greedy_policy(mdp, &v), sweeps: residuals.len(), v, residuals, converged }
}

/// π(x) = argmax_u Q(x, u). Optimal once V = V*; within 2γε/(1−γ) before that.
pub fn greedy_policy<const A: usize>(mdp: &Mdp<A>, v: &DVector<f64>) -> Vec<u8> {
    (0..mdp.n_states).map(|x| mdp.backup(v.as_slice(), x).1).collect()
}

Policy evaluation as a sparse solve

The exact evaluation step is where faer earns its place. The matrix IγPπI - \gamma P_\pi is sparse, nonsymmetric, and — for γ<1\gamma < 1 — strictly diagonally dominant by rows, which means it is nonsingular and an LU with partial pivoting is stable without any reordering heroics.

crates/ch21_mdp/src/pi.rs
use faer::sparse::{SparseColMat, Triplet};
use faer::prelude::*;
use nalgebra::DVector;
use crate::mdp::Mdp;
use crate::vi::greedy_policy;

/// Solve (I − γ P_π) V^π = r_π exactly.
///
/// Same shape of problem as the normal equations in Chapter 15, and the same
/// crate solves it — the difference is that here the matrix is nonsymmetric, so
/// it is LU rather than Cholesky.
pub fn policy_evaluation<const A: usize>(mdp: &Mdp<A>, pi: &[u8]) -> DVector<f64> {
    let n = mdp.n_states;
    let mut triplets: Vec<Triplet<usize, usize, f64>> = Vec::with_capacity(4 * n);
    let mut rhs = Mat::<f64>::zeros(n, 1);

    for x in 0..n {
        if mdp.absorbing[x] {
            // V(x) ≡ 0: a 1×1 identity row keeps the system square and the
            // absorbing convention explicit rather than implied.
            triplets.push(Triplet::new(x, x, 1.0));
            continue;
        }
        let u = pi[x] as usize;
        triplets.push(Triplet::new(x, x, 1.0));
        for t in &mdp.trans[x][u] {
            // Accumulating duplicates is exactly right here: a self-loop lands
            // on the diagonal and must be subtracted from the identity.
            triplets.push(Triplet::new(x, t.s as usize, -mdp.gamma * f64::from(t.p)));
        }
        rhs[(x, 0)] = mdp.reward[x][u];
    }

    let a = SparseColMat::try_new_from_triplets(n, n, &triplets)
        .expect("policy transition matrix is well formed by construction");
    let v = a.sp_lu().expect("I − γP_π is nonsingular for γ < 1").solve(&rhs);
    DVector::from_iterator(n, (0..n).map(|i| v[(i, 0)]))
}

/// Howard's policy iteration: evaluate exactly, improve greedily, repeat.
pub fn policy_iteration<const A: usize>(mdp: &Mdp<A>, max_iter: usize) -> (DVector<f64>, Vec<u8>, usize) {
    let mut pi = vec![0u8; mdp.n_states];
    let mut v = DVector::zeros(mdp.n_states);

    for it in 0..max_iter {
        v = policy_evaluation(mdp, &pi);
        let next = greedy_policy(mdp, &v);
        // Switch only on a strict improvement. Ties between equally good
        // actions would otherwise make the loop oscillate forever.
        let mut changed = 0usize;
        let mut merged = pi.clone();
        for x in 0..mdp.n_states {
            let (a, b) = (next[x] as usize, pi[x] as usize);
            if a != b && mdp.q(v.as_slice(), x, a) > mdp.q(v.as_slice(), x, b) + 1e-12 {
                merged[x] = next[x];
                changed += 1;
            }
        }
        pi = merged;
        if changed == 0 {
            return (v, pi, it + 1);
        }
    }
    (v, pi, max_iter)
}

Compiling a world into an MDP

This is the function that keeps the book's promise. It takes Chapter 13's occupancy grid and Chapter 9's velocity model and produces a finite MDP with no invented constants.

crates/ch21_mdp/src/gridworld.rs
use rand::rngs::SmallRng;
use rand::SeedableRng;
use rand_distr::{Distribution, Normal};
use crate::mdp::{Mdp, SparseDist, Transition};

/// The eight compass moves, counter-clockwise from north.
pub const MOVES8: [(i32, i32); 8] =
    [(0, 1), (-1, 1), (-1, 0), (-1, -1), (0, -1), (1, -1), (1, 0), (1, 1)];

pub struct GridSpec {
    pub width: usize,
    pub height: usize,
    pub blocked: Vec<bool>,
    /// Payoff collected on *entering* a cell. Goals positive, hazards negative.
    pub payoff: Vec<f64>,
    pub terminal: Vec<bool>,
    /// Probability of veering into *each* lateral neighbour. Intended: 1 − 2s.
    pub slip: f64,
    pub gamma: f64,
    pub step_cost: f64,
    pub no_corner_cutting: bool,
}

/// The slip parameter, derived rather than decreed: drive the Chapter 9
/// velocity model one cell and bin the displacement by which neighbour its
/// bearing points at.
///
/// `sigma_theta` is the heading error the localizer has left on the table, and
/// it dominates. The closed form derived in the text is
///
///     s = Φ( −(π/8) / sqrt(σ_θ² + α₃ℓ²/4) )
///
/// so with α₃ = 0.05 and ℓ = 0.3 m the wheels contribute a fixed 0.034 rad
/// regardless of speed, while fifteen degrees of heading error puts s at 0.068.
/// We sample rather than evaluate the formula so that changing the motion model
/// changes the answer, which is the whole point of deriving it.
pub fn slip_from_velocity_model(
    v: f64, cell: f64, alpha1: f64, alpha3: f64, sigma_theta: f64, seed: u64,
) -> f64 {
    let mut rng = SmallRng::seed_from_u64(seed); // never thread_rng: demos are reproducible
    let dt = cell / v;
    let half_sector = std::f64::consts::FRAC_PI_8;
    let heading = Normal::new(0.0, sigma_theta.max(1e-9)).unwrap();
    let dv = Normal::new(0.0, (alpha1 * v * v).sqrt().max(1e-12)).unwrap();
    let dw = Normal::new(0.0, (alpha3 * v * v).sqrt().max(1e-12)).unwrap();

    const N: usize = 40_000;
    let lateral = (0..N)
        .filter(|_| {
            let th0 = heading.sample(&mut rng);
            let vh = v + dv.sample(&mut rng);
            let wh = dw.sample(&mut rng);
            // Thrun et al., Table 5.3: the arc traced by perturbed (v, ω).
            let (dx, dy) = if wh.abs() < 1e-9 {
                (vh * th0.cos() * dt, vh * th0.sin() * dt)
            } else {
                let r = vh / wh;
                (
                    -r * th0.sin() + r * (th0 + wh * dt).sin(),
                    r * th0.cos() - r * (th0 + wh * dt).cos(),
                )
            };
            dy.atan2(dx).abs() > half_sector
        })
        .count();
    // Both sides together are 2s.
    (lateral as f64 / N as f64 / 2.0).min(0.49)
}

/// Compile a spec into a finite MDP over the eight compass moves.
///
/// The rule, in one sentence: the commanded direction happens with probability
/// 1 − 2s, each 45° neighbour of it with probability s, and any outcome that
/// would leave the map or enter a wall leaves the robot where it was — while
/// still charging for the attempt, because the wheels turned.
pub fn grid_world_mdp(spec: &GridSpec) -> Mdp<8> {
    let n = spec.width * spec.height;
    let s = spec.slip.clamp(0.0, 0.49);
    let idx = |i: i32, j: i32| (j as usize) * spec.width + (i as usize);
    let free = |i: i32, j: i32| {
        i >= 0 && j >= 0 && (i as usize) < spec.width && (j as usize) < spec.height
            && !spec.blocked[idx(i, j)]
    };

    let mut trans = Vec::with_capacity(n);
    let mut reward = Vec::with_capacity(n);
    let mut absorbing = vec![false; n];

    for j in 0..spec.height as i32 {
        for i in 0..spec.width as i32 {
            let x = idx(i, j);
            absorbing[x] = spec.blocked[x] || spec.terminal[x];

            let mut rows: [SparseDist; 8] = Default::default();
            let mut rs = [0.0f64; 8];
            for a in 0..8usize {
                let land = |dir: usize| -> u32 {
                    let (di, dj) = MOVES8[dir % 8];
                    if !free(i + di, j + dj) { return x as u32; }
                    if spec.no_corner_cutting && di != 0 && dj != 0
                        && (!free(i + di, j) || !free(i, j + dj)) {
                        return x as u32;
                    }
                    idx(i + di, j + dj) as u32
                };
                let row = condense(&[
                    Transition { s: land(a),         p: (1.0 - 2.0 * s) as f32 },
                    Transition { s: land(a + 7),     p: s as f32 },
                    Transition { s: land(a + 1),     p: s as f32 },
                ]);
                // r(x,u) = −cost(u) + Σ p(x'|x,u)·payoff(x'), so goal payoff is
                // collected by the *neighbours* of the goal.
                let (di, dj) = MOVES8[a];
                let len = ((di * di + dj * dj) as f64).sqrt();
                rs[a] = -spec.step_cost * len
                    + row.iter().map(|t| f64::from(t.p) * spec.payoff[t.s as usize]).sum::<f64>();
                rows[a] = row;
            }
            trans.push(rows);
            reward.push(rs);
        }
    }

    Mdp {
        n_states: n, trans, reward, gamma: spec.gamma, absorbing,
        action_labels: ["N", "NW", "W", "SW", "S", "SE", "E", "NE"],
    }
}

/// Merge duplicate successors and renormalize. Sparse rows must sum to 1, and
/// after wall-clamping they very often do not without this.
fn condense(pairs: &[Transition]) -> SparseDist {
    let mut out: SparseDist = Vec::with_capacity(pairs.len());
    for t in pairs.iter().filter(|t| t.p > 0.0) {
        match out.iter_mut().find(|o| o.s == t.s) {
            Some(o) => o.p += t.p,
            None => out.push(*t),
        }
    }
    let total: f32 = out.iter().map(|t| t.p).sum();
    for t in out.iter_mut() { t.p /= total; }
    out.sort_unstable_by_key(|t| t.s);
    out
}

The worked example, as a test

crates/ch21_mdp/examples/hallway_ssp.rs
use ch21_mdp::mdp::{Mdp, Transition};
use ch21_mdp::vi::value_iteration;

/// A, B, C, G in a line. `roll` advances w.p. p and costs 1; `lunge` is
/// deterministic and costs 2. γ = 1, G absorbing: a stochastic shortest path.
pub fn hallway_ssp(p: f64, lunge_cost: f64) -> Mdp<2> {
    let mut trans = Vec::new();
    let mut reward = Vec::new();
    for x in 0..4usize {
        if x == 3 {
            trans.push([vec![Transition { s: 3, p: 1.0 }], vec![Transition { s: 3, p: 1.0 }]]);
            reward.push([0.0, 0.0]);
            continue;
        }
        trans.push([
            vec![
                Transition { s: x as u32, p: (1.0 - p) as f32 },
                Transition { s: x as u32 + 1, p: p as f32 },
            ],
            vec![Transition { s: x as u32 + 1, p: 1.0 }],
        ]);
        reward.push([-1.0, -lunge_cost]);
    }
    Mdp {
        n_states: 4, trans, reward, gamma: 1.0,
        absorbing: vec![false, false, false, true],
        action_labels: ["roll", "lunge"],
    }
}

fn main() {
    let mdp = hallway_ssp(0.8, 2.0);
    let out = value_iteration(&mdp, 1e-12, 10_000);
    println!("V* = {:?}", out.v.as_slice());          // [-3.75, -2.5, -1.25, 0.0]
    println!("π* = {:?}", out.policy);                // [0, 0, 0, 0]  (all `roll`)
    println!("sweeps = {}", out.sweeps);              // 24
}

#[cfg(test)]
mod tests {
    use super::*;
    use approx::assert_relative_eq;
    use nalgebra::DVector;

    /// The chapter's hand-computed fixed point, to the digit.
    #[test]
    fn hallway_ssp_worked_example() {
        let mdp = hallway_ssp(0.8, 2.0);
        let out = value_iteration(&mdp, 1e-12, 10_000);
        for (got, want) in out.v.iter().zip([-3.75, -2.5, -1.25, 0.0]) {
            assert_relative_eq!(got, &want, epsilon = 1e-9);
        }
        // Determinism at 2 per cell is a bad deal against 1/0.8 = 1.25.
        assert_eq!(out.policy, vec![0, 0, 0, 0]);
    }

    /// And the first three sweeps, which the chapter prints as a table.
    #[test]
    fn hallway_ssp_first_three_sweeps() {
        let mdp = hallway_ssp(0.8, 2.0);
        let mut v = DVector::zeros(4);
        let want = [
            [-1.0, -1.0, -1.0, 0.0],
            [-2.0, -2.0, -1.2, 0.0],
            [-3.0, -2.36, -1.24, 0.0],
        ];
        // Synchronous, to match the table: Gauss–Seidel would already be ahead.
        for row in want {
            let mut next = v.clone();
            for x in 0..4 { next[x] = mdp.backup(v.as_slice(), x).0; }
            v = next;
            for (got, w) in v.iter().zip(row) {
                assert_relative_eq!(got, &w, epsilon = 1e-12);
            }
        }
    }
}

Putting it together

Run the whole thing on the Apartment: 0.3 m cells, walls inflated by 0.16 m for Rusty's radius, 40×30=120040 \times 30 = 1200 cells of which 927 are free states, eight compass moves, no corner cutting, s=0.2s = 0.2, γ=0.98\gamma = 0.98, a charging dock worth +100+100 and a spill worth 60-60.

methodwork to ϵ=106\epsilon = 10^{-6}notes
value iteration, synchronous (Jacobi)84 sweeps100,800 backups
value iteration, in place (Gauss–Seidel)72 sweeps86,400 backups — 14% fewer, for free
the same, swept in breadth-first order from the goal33 sweeps39,600 backups — the order is the algorithm
policy iteration23 improvements23 sparse solves; agrees with VI to 7×1097 \times 10^{-9}
prioritized sweeping, cold226,050 backups2.6× worse — the heap is pure overhead
prioritized sweeping, repairing one painted hazard4,487 backupsvs 21,600 for warm Gauss–Seidel

Then the check that matters: does the value function mean anything? VV^* at the start cell is 7.8987.898. Twenty thousand seeded rollouts of the greedy policy, sampling transitions from the same sparse rows, average 8.058.05 — within 2%, with a mean journey of 45.5 steps. Monte Carlo meeting dynamic programming on the page, which is the only way to be sure that the number the solver printed is the number the robot will actually experience.

And the sweep across noise, which is the chapter's thesis in five rows:

ss0.00.00.10.10.20.20.30.30.40.4
V(x0)V^*(x_0)20.0120.0113.4813.487.907.901.451.453.15-3.15
mean steps to dock37.037.041.341.345.545.551.751.756.956.9
VI sweeps to 10810^{-8}414164647777114114105105

Noise costs value, costs steps, and costs sweeps. Nothing in the policy was "made safe"; the expectation did all of it.

What this chapter is not

Not reinforcement learning. Q-learning, SARSA and actor–critic solve these Bellman equations with pp and rr unknown, learning from samples instead of from a model. That is a genuinely different problem — exploration becomes an issue, and convergence proofs get much harder — and it belongs to Chapter 25. What is worth carrying forward is that the equation being solved is the one above, unchanged.

Not continuous state. Discretizing a 3-DOF pose at any useful resolution is already millions of states, and adding velocities makes it hopeless. The answer in practice is not a finer grid but a different algorithm: sample trajectories from the current state, evaluate them, act, throw them away, repeat. That is Chapter 23, where the value function of this chapter reappears as the terminal cost that makes a short horizon behave like a long one.

Not observable. Here is the bridge, and it is worth being blunt about. Take the Policy Painter's converged policy — provably optimal, arrows correct in every cell — and kidnap Rusty. The policy is still optimal. It is also useless, because π(x)\pi(x) requires xx, and after a kidnapping the robot has a belief, not a state. Nothing in this chapter's machinery has an input port for a distribution.

Chapter 22 fixes this in the only way the mathematics allows: promote the belief to the state. The belief-MDP is a genuine MDP over a continuous, high-dimensional space, the Bellman equation is unchanged, and every algorithm here still applies in principle. Whether they apply in practice is what makes that chapter hard. One of them — QMDP — is nothing more than this chapter's QQ^* averaged against the belief, xb(x)Q(x,u)\sum_x b(x) Q^*(x,u), so keep the QQ-values; you will need them in about twenty pages.

Exercises

  1. Foundation exerciseDifficulty 1 of 3The max inequality, and when it is tight

    Prove maxuaumaxubumaxuaubu\left| \max_u a_u - \max_u b_u \right| \le \max_u \left| a_u - b_u \right| for finite index sets, and exhibit vectors a,bR3a, b \in \mathbb{R}^3 where it holds with equality. Then find vectors where the left side is 00 and the right side is as large as you like, and say in one sentence what that means for the tightness of the contraction bound on a real gridworld.

  2. Foundation exerciseDifficulty 2 of 3The hallway in closed form

    For the four-cell hallway with success probability pp (and lunge disabled), derive V(A),V(B),V(C)V(A), V(B), V(C) in closed form and verify the p=0.8p = 0.8 numbers. Then: at what lunge cost does the optimal policy start preferring it, and is the answer the same at every state? Finally, predict how many synchronous sweeps value iteration needs to reach VkV<0.01\lVert V_k - V^* \rVert_\infty < 0.01 using the geometric bound with the effective contraction modulus 1p1 - p, and compare with the measured count.

  3. Foundation exerciseDifficulty 3 of 3Why undiscounted is harder

    Build two four-state undiscounted goal-absorbing MDPs, both starting value iteration from V0=0V_0 = 0. On the first, value iteration diverges: some state's value runs to -\infty. On the second it converges to a finite fixed point whose optimal policy never reaches the goal. Say which of the two conditions in the SSP box each one violates. (Hint for the second: one free action is enough.) Then repair each in two ways — a strictly positive cost on every action, and a discount γ<1\gamma < 1 — and say what each repair changes about the optimal policy, not merely about the solver's ability to find it.

  4. Conceptual exerciseDifficulty 2 of 3Predict the cliff

    In the Cliff Run, before committing your prediction: estimate ss^* for the default geometry using only the two closed forms in the derivation and a calculator. Then use the widget's guess slider to commit, reveal, and bisect with the slip slider to find where the arrow at the start actually flips. Finally — and this is the interesting part — explain why the slip at which realized runs start preferring the detour is noticeably higher than ss^*, and what that gap is made of.

  5. Conceptual exerciseDifficulty 2 of 3Make the discount change the topology

    Using the Policy Painter, construct a painting in which lowering γ\gamma does not merely make the arrows lazier but routes the robot through a different doorway. Record both arrow fields and both values at the start cell, and explain the mechanism in terms of the effective horizon 1/(1γ)1/(1-\gamma) and the two routes' lengths. Then predict, before checking, whether raising the slip makes the effect appear at a higher or lower γ\gamma.

  6. Practical exerciseDifficulty 2 of 3Prioritized sweeping, and when it loses

    Implement prioritized_sweeping in Rust with a BinaryHeap keyed on the Bellman residual and the priority rule from the algorithm box. Reproduce both halves of this chapter's measurement on the Apartment: that it loses to Gauss–Seidel on a cold solve (226k backups vs 86k), and that it wins by roughly 5× when repairing a single changed reward cell. Plot backups against TVV\lVert TV - V \rVert_\infty for both, and explain the crossover in terms of what fraction of states have a nonzero residual.

  7. Practical exerciseDifficulty 3 of 3The wave-front planner, generated

    Add an SSP mode (γ=1\gamma = 1) to the crate with proper-policy detection: build the predecessor graph with petgraph, run a reverse traversal from the goal set, and flag every state that cannot reach a goal instead of letting its value run to -\infty. Then set the slip to zero and assert that V-V^* equals, cell for cell, the distance field produced by Chapter 20's wave-front planner on the same map. If the assert fails, the most likely culprit is diagonal cost: check that you charged 2\sqrt{2}.

References

  1. Bellman, R. (1957) A Markovian Decision Process. Indiana University Mathematics Journal 6(4), 679–684.doi:10.1512/iumj.1957.6.56038 (opens in a new tab)

    The five-page paper that named the object and wrote down the optimality equation this whole chapter revolves around.

  2. Howard, R. A. (1960) Dynamic Programming and Markov Processes. MIT Press.link to Dynamic Programming and Markov Processes (opens in a new tab)

    Policy iteration's origin, still the clearest account of why evaluate-then-improve terminates. The algorithm in this chapter's second box is Howard's, essentially unmodified.

  3. Bertsekas, D. P. and Tsitsiklis, J. N. (1991) An Analysis of Stochastic Shortest Path Problems. Mathematics of Operations Research 16(3), 580–595.doi:10.1287/moor.16.3.580 (opens in a new tab)

    The proper-policy conditions in the SSP box, proved. This is the reference for why undiscounted navigation MDPs are well posed — and exactly when they are not.

  4. Moore, A. W. and Atkeson, C. G. (1993) Prioritized sweeping: Reinforcement learning with less data and less time. Machine Learning 13(1), 103–130.doi:10.1007/BF00993104 (opens in a new tab)

    The priority rule implemented in this chapter's third algorithm box, including the argument for keying on how much of a change can reach each predecessor.

  5. Barto, A. G., Bradtke, S. J., and Singh, S. P. (1995) Learning to act using real-time dynamic programming. Artificial Intelligence 72(1), 81–138.doi:10.1016/0004-3702(94)00011-O (opens in a new tab)

    Asynchronous DP made into a control architecture: back up only the states you actually visit, and act on the half-converged value function. The formal justification for what the Policy Painter does every frame.

  6. Thrun, S., Burgard, W., and Fox, D. (2005) Probabilistic Robotics. MIT Press.link to Probabilistic Robotics (opens in a new tab)

    Chapter 15 is this chapter's baseline: the MDP framing, the payoff notation, and the MDP_value_iteration algorithm of Table 15.1, which the first algorithm box reproduces with a stopping rule added.

  7. Kurniawati, H. (2022) Partially Observable Markov Decision Processes and Robotics. Annual Review of Control, Robotics, and Autonomous Systems 5, 253–277.doi:10.1146/annurev-control-042920-092451 (opens in a new tab)

    The modern survey of what happens when you drop this chapter's full-observability assumption. Read the introduction now for the bridge to Chapter 22; read the rest after it.

  8. Akella, P., Dixit, A., Ahmadi, M., Lindemann, L., Chapman, M. P., Pappas, G. J., Ames, A. D., and Burdick, J. W. (2025) Risk-Aware Robotics: Tail Risk Measures in Planning, Control, and Verification. IEEE Control Systems 45(4), 46–78.doi:10.1109/MCS.2025.3577050 (opens in a new tab)

    What to do when maximizing the expectation is not what you meant — CVaR and other tail measures, and what they cost you in tractability. The honest answer to the Cliff Run's caveat about individual runs.