Back to Reinforcement Learning
AI/ML · Reinforcement Learning

MDPs & the Bellman Equations

Decide once what counts as the state, and make it a state that renders the past irrelevant — everything you need to know about history is in the current description. Then the value of a situation decomposes: what you get now, plus the discounted value of wherever you land. That recursion is the whole idea, and it is the reason a problem spanning a thousand steps can be attacked one step at a time. What makes it more than a tautology is the discount. Because a future reward is worth strictly less than a present one, the one-step backup shrinks disagreements between any two guesses by a constant factor, so repeating it drives every starting point to the same answer. Existence, uniqueness and a convergence rate all fall out of that single fact. The cost is stated equally simply: the whole structure rests on the state being genuinely Markov, and when it is not, the equations still have a fixed point — just not the one anybody wanted, and nothing in the arithmetic says so.

ReinforcementControl

A formalism rather than a learning procedure, so it has no paradigm of its own — it is listed under reinforcement because it is the problem statement every method in this category approximates. Worth noting that the Bellman recursion long predates reinforcement learning and is equally at home in operations research and optimal control, where the dynamics are known and the question is purely computational.

Also known as: Markov Decision Process, Bellman optimality equation, Bellman expectation equation, Dynamic programming principle, Optimal substructure

Intuition

What it actually does

Decide once what counts as the state, and make it a state that renders the past irrelevant — everything you need to know about history is in the current description. Then the value of a situation decomposes: what you get now, plus the discounted value of wherever you land. That recursion is the whole idea, and it is the reason a problem spanning a thousand steps can be attacked one step at a time. What makes it more than a tautology is the discount. Because a future reward is worth strictly less than a present one, the one-step backup shrinks disagreements between any two guesses by a constant factor, so repeating it drives every starting point to the same answer. Existence, uniqueness and a convergence rate all fall out of that single fact. The cost is stated equally simply: the whole structure rests on the state being genuinely Markov, and when it is not, the equations still have a fixed point — just not the one anybody wanted, and nothing in the arithmetic says so.

Assumptions it makes
  • The state is Markov — it contains everything about the history that matters for the future, which is a substantive claim and never a free one
  • The transition kernel and reward are stationary; a changing environment invalidates the fixed point being computed rather than degrading it
  • The discount is strictly below one, or the episode terminates with probability one under every policy, since otherwise the contraction and its guarantees are gone
  • State and action spaces are enumerable, which is exactly the assumption every other method in this category exists to relax
  • The reward function expresses the objective completely, because anything left out is not traded off — it is ignored
Objective

Fixed-point condition

Two equations, and the difference between them is the most useful thing in the formalism. The second, for a FIXED policy, is linear in V — it is a system of as many equations as there are states, and it can be solved exactly by matrix inversion. The first, for the optimal policy, has a max in it and is therefore nonlinear, with no closed form at any size. That single structural difference is why every method in this category alternates between an evaluation step that is easy and an improvement step that is not. Neither equation is a loss being minimized over data: they are self-consistency conditions, satisfied when a value function equals its own one-step lookahead. Read the recursion as the statement that an optimal decision sequence has optimal continuations — Bellman's principle of optimality — which is exactly what licences solving a long-horizon problem one step at a time. And note where the discount sits. It is inside the recursion, multiplying the successor value, so it compounds: a reward k steps away is worth gamma to the k. That is why gamma is an effective horizon of roughly one over one-minus-gamma steps rather than a preference about patience, and why moving it from 0.9 to 0.99 changes the problem being solved rather than tuning the solution to it.

the transition kernel — the environment's dynamics, and the thing that is known here and unknown everywhere else in this category
expected immediate reward; the specification of the task, and the only place preferences enter
the discount, which is what makes the backup a contraction and therefore what makes a unique solution exist
the optimality equation's nonlinearity — the reason control has no closed form while evaluation does
a fixed policy; with it the max disappears and the second equation is linear in V
Optimization

How it is minimized

Not an algorithm — a fixed-point characterization whose operator is a gamma-contraction, which is the property every method downstream exploits

the Bellman optimality operator: one backup applied to every state at once
the sup-norm — the worst error over all states, which is the norm the contraction holds in
the contraction modulus, so every backup shrinks the worst-case error by at least this factor
the fixed point, unique by the Banach theorem and reachable from any starting V

The contraction inequality is the load-bearing result, and almost everything practical follows from it mechanically. Banach's fixed-point theorem applies directly: the operator has exactly one fixed point, iterating it from any starting value converges to that point, and the error falls geometrically at rate gamma. That gives a computable stopping rule most people never use — if two successive iterates differ by less than epsilon in sup-norm, the current estimate is within gamma·epsilon over one-minus-gamma of the truth — so a value-iteration run has an actual error bound rather than a vibe about convergence. The contraction also explains the cost of a long horizon honestly: at gamma of 0.99 each backup removes one percent of the error, so reaching a given tolerance takes roughly a hundred times as many sweeps as it would at gamma of 0.9. That is not an implementation inefficiency, it is the problem being harder. Two boundaries worth naming. At gamma equal to one the contraction is gone, and the equations only have a solution under extra structure — proper episodic termination, or a switch to the average-reward formulation, which is a genuinely different set of equations rather than a limiting case. And the whole argument runs in sup-norm, which is exactly the norm function approximation does not preserve: a projection that is a contraction in least squares need not be one in sup-norm, and that mismatch is the formal root of the instability that appears the moment a table is replaced by a network.

Hyperparameters
  • discount (gamma)

    Not a preference but a specification of the horizon, roughly one over one-minus-gamma steps. It also sets the convergence rate, so it trades the problem you solve against the cost of solving it (0.9 to 0.999)

  • state representation

    The Markov contract. Everything in the formalism is conditional on it, and violating it changes which fixed point exists without changing any arithmetic (problem-specific)

  • reward specification

    The entire task definition. Every downstream method optimizes exactly what is written here, which is why misspecification is silently and enthusiastically exploited (problem-specific)

  • action set

    What the agent may do. Enumerable actions make the max tractable; continuous ones make the optimality equation unsolvable directly (discrete and small, or a parameterized family)

  • horizon and termination

    Finite-horizon problems have a time-indexed value function and no fixed point at all; infinite-horizon ones need either discounting or proper termination (episodic, or infinite with gamma below 1)

Convergence & cost

Guaranteed and quantified for the tabular case with a known model: a unique fixed point exists, value iteration reaches it geometrically at rate gamma, and the sup-norm gap between successive iterates bounds the distance to it. The failures are all in the modelling rather than the arithmetic, which is what makes them hard to see. A non-Markov state is the central one — the equations remain perfectly well-posed and converge to the fixed point of the aggregated process, which is generally not the value function of anything anyone wanted; the value estimates look stable and the derived policy is simply wrong, with no diagnostic in the numbers. A discount of exactly one removes the contraction and the iteration diverges unless every policy is proper, which is easy to violate by accident when a state can loop forever. Reward scale interacts with everything downstream: multiplying rewards by a constant multiplies values by the same constant, harmless in a table and destabilizing the moment a function approximator with a fixed learning rate is involved. And sup-norm is the norm this all lives in, so the contraction argument gives no guarantee whatsoever once values are projected onto an approximator — the deadly triad of bootstrapping, off-policy sampling and approximation is exactly the statement that the contraction can fail in the projected norm.

Policy evaluation is a linear system: O(|S|^3) solved directly, or O(|S|^2·|A|) per sweep iteratively, which is usually far cheaper for sparse dynamics since the cost is really the number of reachable successors rather than the number of states squared. The optimality equation has no closed form, so it is solved iteratively, with the number of sweeps set by gamma and the tolerance. The binding constraint is neither: it is that |S| grows exponentially in the number of state variables, so the tabular guarantees are exact and unreachable for almost every real problem — which is the entire reason the rest of this category exists.

Applications

How, where, and why it is used

Time-Series Forecasting

Not applicable

The formalism requires actions that change the transition, and a forecast does not act on the series it describes — the Markov assumption is shared with state-space models, but without a decision variable there is no max, no policy, and nothing for the Bellman recursion to say.

Anomaly Detection

Not applicable

Detection scores observations and takes no action that alters what happens next, so there is no transition kernel and no return to optimize; a large Bellman residual is a statement about an unconverged value function, never about the data.

Optimization

Primary fit
How

The formalism IS the optimization problem: a sequential decision problem stated as states, actions, a transition kernel, a reward and a discount, whose solution is characterized by a fixed point rather than by a gradient. Everything downstream is a different relaxation of "the kernel is known and the state space is enumerable".

Where
  • Optimal substructure as a proof technique — the principle of optimality is what licences solving a long problem one step at a time
  • The contraction property as a convergence certificate, with a computable error bound from successive iterates
  • Linear evaluation against nonlinear control, which is the structural reason every method alternates between the two
  • The discount as a horizon specification, where changing it changes the problem rather than tuning the solver
Why

Worth studying in its own right rather than as preamble, because three lessons here transfer directly to problems that have nothing to do with agents. First, that a recursion plus a contraction gives existence, uniqueness and a rate all at once — a pattern that recurs in numerical analysis, in equilibrium computation and in anything solved by successive approximation. Second, that a maximum inside a recursion is what destroys a closed form: the same equations without the max are a linear solve, and recognizing which of the two a problem is has decided more architectures than any algorithmic preference. Third, that the norm in which a contraction holds is part of the result and not a technicality — the entire instability of deep reinforcement learning is the observation that the sup-norm contraction says nothing once values are projected in least squares. The honest limitation is equally clean: the guarantees are exact and the state space is exponential, so this is the reference solution nobody can compute.

Featurization
  • Check that the state genuinely satisfies the Markov property before anything else; every guarantee below is conditional on it and none of them fail loudly when it breaks
  • Choose the discount from the horizon the problem actually has, then accept the convergence cost it implies rather than lowering it to make the solver finish
  • Verify each row of the transition kernel sums to one, since a mis-normalized kernel converges perfectly to the value of a different process
  • State the reward as the objective rather than as a hint toward it; every method in this category optimizes the literal text
Evaluation

The Bellman residual in sup-norm, which is the only quantity here with a guarantee attached: the gap between successive iterates bounds the distance to the true value function by gamma over one-minus-gamma times that gap. For a derived policy, compare its exact evaluation against the optimal value rather than comparing value estimates, since two value functions can differ substantially and induce the same greedy policy.

Pitfalls
  • Treating gamma as a tuning knob, when lowering it solves a shorter-horizon problem and reports the answer to a different question
  • A non-Markov state, which converges cleanly to the fixed point of the wrong process with no diagnostic anywhere
  • Gamma at exactly one without proper termination, where the contraction is gone and the iteration diverges
  • Assuming the contraction survives function approximation; it holds in sup-norm and approximators do not preserve that norm
Also applied in

Control & Operations

Primary fit
How

The formalism was built for this and reads as a literal description of an operations problem: the state is the physical or logistical configuration, actions are the interventions available, the kernel is the system dynamics, and the reward is throughput minus cost. Where the dynamics are genuinely known — a queue, an inventory, a machine with a documented failure model — the equations can be solved rather than learned.

Where
  • Inventory and replenishment policies, where the classical order-up-to results are Bellman solutions in closed form
  • Maintenance scheduling as an optimal-stopping problem, one of the few MDPs with a genuinely analytic answer
  • Queueing and admission control, where the kernel follows from the arrival and service processes
  • Equipment replacement and capacity planning over a long horizon with known cost structure
Why

The strongest fit in this reference for a reason worth stating plainly: operations research solved these problems with these equations decades before reinforcement learning existed, and where the dynamics are known the exact solution is available and no learning is required. That is the first thing to check, and it is checked far too rarely — a team that reaches for a policy-gradient method on a problem with a known transition kernel has chosen a sampling approximation to something it could have computed. The structural limitation is the state space rather than the mathematics. Realistic operations problems have many state variables and the table is exponential in their number, which is what pushes practice toward approximation, decomposition into independent subproblems, or a receding-horizon controller that re-solves a short problem repeatedly.

Featurization
  • Look for a known kernel first; if the dynamics are documented, solve the equations rather than sampling an approximation to them
  • Exploit structure before scaling — monotone value functions, threshold-optimal policies and decomposable state often collapse the problem entirely
  • Encode operational constraints as unavailable actions rather than as reward penalties, which the optimizer will otherwise trade against
  • Keep the discount tied to a real financial horizon, where an operations problem usually has one already
Evaluation

Exact policy evaluation against the incumbent policy, which is available here precisely because the kernel is known — this is the rare domain where the comparison is a calculation rather than an experiment. Simulate the resulting policy against a kernel perturbed within its estimation error, since the sensitivity of the answer to the dynamics is usually larger than the gap between candidate policies.

Pitfalls
  • Learning a policy for a system whose dynamics are already written down
  • A state space that multiplies out to more entries than can be enumerated, discovered after the formulation is fixed
  • Constraints expressed as penalties, which turns a hard limit into an exchange rate
  • Ignoring kernel estimation error, when the policy is often more sensitive to it than to the discount

Causal Inference & Uplift

Viable
How

The transition kernel is an interventional distribution rather than an observational one — P(s' | s, do(a)) — which makes an MDP a causal model with a time index. Evaluating a policy that was never run is then exactly the identification problem, and the sequential importance weights that solve it are inverse propensity weighting applied at every step.

Where
  • Off-policy evaluation from logged decisions, which is sequential IPTW under another name
  • Dynamic treatment regimes in clinical settings, where the same equations appear with different vocabulary
  • Sequential confounding, where the Markov assumption plays the role of sequential ignorability
  • Policy comparison from observational logs, before any live experiment is contemplated
Why

Worth including because the correspondence is exact rather than decorative, and it is the fastest way to import decades of causal-inference care into a reinforcement-learning project. The Markov assumption is doing the work that sequential ignorability does: it asserts that the state contains everything that confounds the action and the outcome, which is an untestable substantive claim and not a modelling convenience. The consequences are the familiar ones. Importance weights multiply across timesteps, so their variance grows geometrically with the horizon and an off-policy estimate over more than a handful of steps is usually too noisy to act on — the reinforcement-learning literature calls this the curse of horizon and the causal literature met it first. Positivity has the same bite: a state-action pair the logging policy never chose is not identified, and no estimator recovers it. The honest boundary is that reinforcement learning usually assumes away the confounding that causal inference exists to handle, so the fit is a lens on off-policy evaluation rather than a claim that MDPs solve causal problems.

Featurization
  • Treat the Markov assumption as the identification assumption it is, and argue for it substantively rather than asserting it
  • Check positivity per state-action pair; an action the logging policy never took in a state is not identified at any sample size
  • Report the effective sample size of the importance weights, which collapses fast as the horizon grows
  • Prefer doubly robust or model-assisted estimators over raw importance sampling beyond a few steps
Evaluation

Off-policy value estimates with confidence intervals rather than point estimates, since the variance is the finding here more often than the mean. Where a randomized logging policy exists even partially, use it: a little deliberate exploration in the logs is worth more than any estimator.

Pitfalls
  • Unobserved confounding, which the Markov assumption assumes away rather than addresses
  • Importance weights compounding over a long horizon until the estimate is dominated by a handful of trajectories
  • Positivity violations reported as small values rather than as unidentified quantities
  • Reading an off-policy point estimate as a decision when its interval spans the incumbent

Recommendation & Ranking

Adapted
How

Frame a session as an episode: the state summarizes interaction history, actions are items or slates to surface, and the reward is engagement over the session rather than on the next click. The formalism is what makes the long-horizon objective expressible at all.

Where
  • Session-level objectives, where the sequence of recommendations matters more than any single one
  • Stating the exploration-exploitation structure precisely before choosing between a bandit and a full MDP
  • Slate and sequence problems where an action changes what the user will consider next
  • Making the feedback loop explicit: today's policy generates tomorrow's training data, which is a transition, not noise
Why

A useful framing and a poor literal application, and being clear about which is which saves a great deal of work. The framing earns its place because ranking genuinely is sequential — the exposure decision changes the state — and a pointwise model cannot express that at all. The literal application fails on both terms of the formalism: the action space is the entire catalogue, so the max over actions is intractable without a candidate-generation stage, and the state is a user, which is emphatically not Markov in any compact representation. What usually survives is the vocabulary rather than the solution method: stating the problem as an MDP clarifies that a contextual bandit is the one-step special case, and the bandit is very often the right thing to build.

Featurization
  • Compress the session into a state deliberately, since the raw history is unbounded and the Markov claim is about the compression
  • Restrict the action set with candidate generation before any max over actions is contemplated
  • Decide explicitly whether the problem is one-step; if it is, a contextual bandit is the correct specialization and not a compromise
  • Log the policy that generated each impression, or off-policy evaluation later is not identified
Evaluation

Online tests decide. Off-policy estimates on logged data are worth computing first and worth distrusting, because the horizon inflates importance-weight variance and the logs reflect the incumbent policy's exposure decisions as much as user preference.

Pitfalls
  • Treating a user as a Markov state, which is the assumption doing all the work and the one least examined
  • A max over the full catalogue, which is intractable and usually silently replaced by something else
  • Building a full MDP where a contextual bandit captures nearly all the value
  • Unlogged propensities, which make every later off-policy question unanswerable
Deployment

What it costs to run

Training

There is no training. Solving the equations exactly costs one linear solve per policy evaluation, or a sequence of sweeps whose count is set by the discount and the tolerance — at gamma of 0.99, reaching a given accuracy takes roughly a hundred times the sweeps it would at 0.9, which is the problem being harder rather than the solver being slow. The real cost is that a sweep touches every state, and the state count is exponential in the number of state variables. Illustrative, not a measured benchmark.

Inference

A single argmax over the action row of a solved value function: microseconds, and completely inspectable — every action considered has a number attached and the reason one was chosen can be printed. This is the property every approximate method in this category gives up, and it is worth a great deal in any setting where a policy has to be defended to somebody.

Retraining

Re-solve when the kernel or the reward changes, and note that this is a discrete event rather than a drift: a re-layout, a new price, a changed service-level target invalidates the solution completely rather than degrading it gradually. Where the kernel is estimated from data, re-estimating it on a schedule and re-solving is cheap; where it is specified by hand, the review cadence is a process question rather than a technical one.

Drift & monitoring
  • Bellman residual in sup-norm on the deployed value function, which detects a moved kernel directly rather than by proxy
  • Realized transition frequencies against the assumed kernel, since a specified model drifts from reality silently and the policy keeps acting confidently
  • State-visitation coverage against the states the solution assumed reachable; an unreachable state with a confident value is a specification error surfacing late
  • Realized discounted return against the value function's prediction, which is the end-to-end check that subsumes the others
  • Frequency of states with no defined action, which is a fallback path that usually exists and usually has never been exercised
Production gotchas
  • The reward function is the specification, not a hint. Every method in this category optimizes the literal text, and a mismatch with the real objective is exploited rather than smoothed over
  • The Markov claim is an assumption about the state representation and nothing checks it. A non-Markov state yields a clean, converged, confident solution to a different problem
  • Gamma is part of the problem statement. Changing it to make a solver converge quietly answers a different question, and the answer will still look reasonable
  • A transition kernel whose rows do not sum to one converges perfectly well to the value of a process that leaks probability, with no error raised anywhere
  • Tie-breaking in the argmax is a policy decision. Exact ties are common in structured problems, and an arbitrary rule can produce a policy that oscillates between equivalent actions
  • Sup-norm contraction is what all the guarantees rest on, and function approximation does not preserve it — carrying the tabular intuition across that boundary is the origin of most deep-RL instability
  • A finite-horizon problem has a time-indexed value function and no fixed point at all; solving it as if it were infinite-horizon is a silent modelling error
Trade-offs

Pros and cons, in context

Strengths
  • Existence, uniqueness and a convergence rate all follow from one contraction inequality

    Unusually strong for anything in this reference, and the reason the formalism is worth learning precisely rather than approximately. It also supplies a computable error bound from successive iterates, which almost nobody uses

  • The recursion turns a horizon-length problem into a one-step problem

    Bellman's principle of optimality is what makes sequential decisions tractable at all. The same pattern — optimal substructure plus a contraction — recurs well outside reinforcement learning

  • Policy evaluation is a linear system with an exact solution

    The max is the only nonlinearity, so removing it makes the problem a matrix inverse. Recognizing which of the two equations a task needs has settled more design questions than any algorithmic preference

  • A solved value function is completely inspectable

    Every action in every state has a number and a reason. This is what every approximate method trades away, and it is worth a great deal wherever a policy must be explained rather than merely deployed

  • It states the problem precisely enough that the right specialization becomes obvious

    Writing a task as an MDP frequently reveals that it is one-step, or that the dynamics are known, or that the state cannot be Markov — each of which points at a simpler method than the one originally planned

Limitations
  • The state space is exponential in the number of state variables

    The guarantees are exact and unreachable for almost every real problem. This single fact is why the rest of this category exists, and why the exact solution is a reference point rather than a method

  • Everything is conditional on the Markov property and nothing checks it

    A non-Markov state produces a converged, confident solution to the aggregated process instead. There is no diagnostic in the numbers, which makes this the most dangerous failure here

  • The transition kernel must be known, which it usually is not

    Where it is known the equations should be solved rather than learned, and that check is skipped far too often. Where it is not, every method downstream is paying sample efficiency to estimate it implicitly

  • The optimality equation has no closed form at any size

    The max makes it nonlinear, so control is always iterative even when the model is fully known. Only the fixed-policy evaluation step is a solve

  • The contraction holds in sup-norm, which function approximation does not preserve

    Every stability guarantee stops at the boundary between a table and an approximator, and carrying the intuition across it anyway is the formal root of the deadly triad

  • A long horizon is expensive in a way that cannot be tuned away

    Convergence rate is the discount, so gamma at 0.999 needs roughly a thousand times the sweeps of 0.9 for the same tolerance. Lowering it to finish sooner answers a different question

Implementation

Intuitive, idiomatic, optimized

The same algorithm in three languages, each progressing through Kent Beck's three stages. Every optimization names the lever it pulls and the trade-off it accepts — cost profiles are illustrative, not measured benchmarks.

1. Intuitive

The math, transcribed literally. No dependencies.

"""MDPs and the Bellman equations, transcribed literally.

No library. A tiny MDP held as nested lists, so every claim in the
theory becomes something that can be computed rather than quoted:

    V*(s)  = max_a [ R(s,a) + gamma * sum_s' P(s'|s,a) V*(s') ]
    V^pi(s) =  sum_a pi(a|s) [ R(s,a) + gamma * sum_s' P(s'|s,a) V^pi(s') ]

Four things to read for.

1. The two equations are structurally different. The second is LINEAR
   in V, and solve_policy_value_exactly() inverts it by Gaussian
   elimination with no iteration at all. The first has a max in it and
   is nonlinear, so value_iteration() can only approach it. That single
   difference is why every method in this category alternates between a
   cheap evaluation step and an expensive improvement step.

2. contraction_check() measures the inequality the whole formalism
   rests on: one backup shrinks the sup-norm gap between any two value
   functions by at least gamma.

3. value_iteration() returns a real error BOUND, not a sense that it
   converged. If two successive iterates differ by eps then the current
   estimate is within gamma*eps/(1-gamma) of the truth.

4. markov_violation_demo() aggregates two states that behave
   differently and solves again. Nothing errors. The iteration
   converges cleanly to the fixed point of a process nobody wanted, and
   the derived policy is wrong -- which is the most dangerous failure
   here precisely because the numbers look healthy.
"""

Kernel = list[list[list[float]]]   # P[state][action][next_state]
Reward = list[list[float]]         # R[state][action]
Policy = list[list[float]]         # pi[state][action]


def sup_norm(left: list[float], right: list[float]) -> float:
    """Largest absolute difference over states.

    The norm the contraction holds in, and not an arbitrary choice: the
    guarantees below are all sup-norm statements, and they say nothing
    about a least-squares projection -- which is exactly the gap where
    deep reinforcement learning loses its stability argument.
    """
    return max(abs(a - b) for a, b in zip(left, right))


def expected_next_value(
    kernel: Kernel, values: list[float], state: int, action: int
) -> float:
    total = 0.0
    for next_state, probability in enumerate(kernel[state][action]):
        total += probability * values[next_state]
    return total


def bellman_optimality_backup(
    kernel: Kernel, reward: Reward, values: list[float], gamma: float
) -> list[float]:
    """One application of T: the max makes this nonlinear.

    Every state updated from the same input values, which is what makes
    this an operator rather than a sweep -- and what makes the
    contraction argument apply cleanly.
    """
    updated = []
    for state, action_rewards in enumerate(reward):
        best = max(
            action_rewards[action] + gamma * expected_next_value(kernel, values, state, action)
            for action in range(len(action_rewards))
        )
        updated.append(best)
    return updated


def bellman_expectation_backup(
    kernel: Kernel, reward: Reward, policy: Policy, values: list[float], gamma: float
) -> list[float]:
    """One application of T^pi: no max, therefore linear in V.

    Read against the function above. The only difference is that the
    max became an average under a fixed policy, and that is the entire
    reason this equation has a closed-form solution and the other does
    not.
    """
    updated = []
    for state, action_probabilities in enumerate(policy):
        total = 0.0
        for action, probability in enumerate(action_probabilities):
            if probability == 0.0:
                continue
            total += probability * (
                reward[state][action]
                + gamma * expected_next_value(kernel, values, state, action)
            )
        updated.append(total)
    return updated


def solve_policy_value_exactly(
    kernel: Kernel, reward: Reward, policy: Policy, gamma: float
) -> list[float]:
    """V^pi = (I - gamma P_pi)^-1 R_pi, by Gaussian elimination.

    Written out because the fact that this is possible at all is the
    structural point. Policy evaluation is a linear system with as many
    equations as states; no iteration, no tolerance, no convergence
    criterion. The optimality equation admits nothing of the kind at
    any size.
    """
    n_states = len(reward)

    # Build (I - gamma P_pi) augmented with R_pi.
    matrix = []
    for state in range(n_states):
        row = [0.0] * (n_states + 1)
        row[state] = 1.0

        for action, probability in enumerate(policy[state]):
            if probability == 0.0:
                continue
            row[n_states] += probability * reward[state][action]
            for next_state, transition in enumerate(kernel[state][action]):
                row[next_state] -= gamma * probability * transition
        matrix.append(row)

    # Forward elimination with partial pivoting.
    for column in range(n_states):
        pivot_row = max(range(column, n_states), key=lambda r: abs(matrix[r][column]))
        if abs(matrix[pivot_row][column]) < 1e-12:
            raise ValueError(
                f"(I - gamma P_pi) is singular at column {column}; this happens at "
                "gamma = 1 with a policy that never terminates, where the equation "
                "has no solution rather than a hard one"
            )
        matrix[column], matrix[pivot_row] = matrix[pivot_row], matrix[column]

        for row in range(column + 1, n_states):
            factor = matrix[row][column] / matrix[column][column]
            if factor == 0.0:
                continue
            for position in range(column, n_states + 1):
                matrix[row][position] -= factor * matrix[column][position]

    # Back substitution.
    values = [0.0] * n_states
    for state in reversed(range(n_states)):
        total = matrix[state][n_states]
        for column in range(state + 1, n_states):
            total -= matrix[state][column] * values[column]
        values[state] = total / matrix[state][state]
    return values


def value_iteration(
    kernel: Kernel, reward: Reward, gamma: float, tolerance: float = 1e-8, max_sweeps: int = 10_000
):
    """Iterate T to its fixed point, with an actual error bound.

    The contraction gives more than "it converged": if successive
    iterates differ by eps in sup-norm, the current estimate is within
    gamma*eps/(1-gamma) of V*. That bound is computable, free, and
    almost never reported.

    Note the sweep count against gamma. At 0.9 each backup removes a
    tenth of the error; at 0.999 it removes a thousandth. The hundredfold
    difference in sweeps is the problem being harder, not the solver
    being slow.
    """
    if not 0.0 <= gamma < 1.0:
        raise ValueError(
            f"gamma must lie in [0, 1); got {gamma}. At exactly 1 the backup is not a "
            "contraction and none of the guarantees below hold"
        )

    values = [0.0] * len(reward)
    for sweep in range(max_sweeps):
        updated = bellman_optimality_backup(kernel, reward, values, gamma)
        gap = sup_norm(updated, values)
        values = updated

        if gap < tolerance:
            return {
                "values": values,
                "sweeps": sweep + 1,
                "final_gap": gap,
                # The bound, from the contraction. Not an estimate.
                "error_bound": gamma * gap / (1.0 - gamma),
            }

    return {
        "values": values,
        "sweeps": max_sweeps,
        "final_gap": gap,
        "error_bound": gamma * gap / (1.0 - gamma),
    }


def greedy_policy(kernel: Kernel, reward: Reward, values: list[float], gamma: float) -> list[int]:
    """Act greedily on a value function.

    Tie-breaking is a policy decision, not an implementation detail:
    exact ties are common in structured problems, and min() here picks
    the lowest index deterministically so a re-solve does not silently
    produce a different policy with the same value.
    """
    actions = []
    for state, action_rewards in enumerate(reward):
        scored = [
            (
                action_rewards[action] + gamma * expected_next_value(kernel, values, state, action),
                action,
            )
            for action in range(len(action_rewards))
        ]
        best_value = max(score for score, _ in scored)
        actions.append(min(action for score, action in scored if score == best_value))
    return actions


def contraction_check(
    kernel: Kernel, reward: Reward, gamma: float, first: list[float], second: list[float]
) -> dict[str, float]:
    """The inequality the entire formalism rests on, measured.

        ||T V - T U||_inf  <=  gamma * ||V - U||_inf

    Everything else -- that a solution exists, that it is unique, that
    iteration reaches it, at what rate -- is Banach's fixed-point
    theorem applied to this one line.
    """
    before = sup_norm(first, second)
    after = sup_norm(
        bellman_optimality_backup(kernel, reward, first, gamma),
        bellman_optimality_backup(kernel, reward, second, gamma),
    )
    return {
        "gap_before": before,
        "gap_after": after,
        "observed_modulus": after / before if before > 0.0 else 0.0,
        "gamma": gamma,
        "holds": after <= gamma * before + 1e-12,
    }


def effective_horizon(gamma: float) -> float:
    """Roughly how many steps the discount actually looks ahead.

    1/(1-gamma). Worth computing rather than intuiting: gamma of 0.99 is
    a hundred-step horizon, which is a different problem from the
    ten-step one at 0.9 rather than a more patient version of it.
    """
    return 1.0 / (1.0 - gamma)


def markov_violation_demo(gamma: float = 0.9) -> dict[str, object]:
    """The failure with no diagnostic.

    Two states that behave differently are merged into one, as any
    coarse state representation does. The aggregated MDP is perfectly
    well-formed: the kernel normalizes, the backup contracts, value
    iteration converges, and the bound is satisfied.

    It converges to the value function of the aggregated process, which
    is not the value function of the original. The derived policy is
    wrong and nothing anywhere says so -- which is why the Markov claim
    has to be argued substantively rather than checked numerically.
    """
    # Three states. From s0, action 0 leads to s1 (good), action 1 to s2
    # (bad). s1 and s2 are absorbing with very different rewards.
    kernel: Kernel = [
        [[0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
        [[0.0, 1.0, 0.0], [0.0, 1.0, 0.0]],
        [[0.0, 0.0, 1.0], [0.0, 0.0, 1.0]],
    ]
    reward: Reward = [[0.0, 0.0], [1.0, 1.0], [-1.0, -1.0]]

    true_solution = value_iteration(kernel, reward, gamma)
    true_policy = greedy_policy(kernel, reward, true_solution["values"], gamma)

    # Now aggregate s1 and s2 into a single state, averaging what an
    # observer who cannot tell them apart would see.
    aggregated_kernel: Kernel = [
        [[0.0, 1.0], [0.0, 1.0]],
        [[0.0, 1.0], [0.0, 1.0]],
    ]
    aggregated_reward: Reward = [[0.0, 0.0], [0.0, 0.0]]

    aggregated_solution = value_iteration(aggregated_kernel, aggregated_reward, gamma)
    aggregated_policy = greedy_policy(
        aggregated_kernel, aggregated_reward, aggregated_solution["values"], gamma
    )

    return {
        "markov_values": true_solution["values"],
        "markov_policy": true_policy,
        "aggregated_values": aggregated_solution["values"],
        "aggregated_policy": aggregated_policy,
        "note": (
            "both runs converged inside their error bounds; the aggregated one "
            "solved a different problem and reported no difficulty doing so"
        ),
    }

One optimality backup is O(|S|·|A|·|S'|) where |S'| is the number of reachable successors, and value iteration needs roughly log(tolerance)/log(gamma) sweeps — about 175 at gamma 0.9 and 1750 at 0.99 for the same tolerance. Exact policy evaluation is one O(|S|³) elimination. Illustrative, not a measured benchmark: the shape worth reading is that the sweep count depends on gamma and not on the problem, which is the contraction rate showing up directly as running time.

2. Idiomatic

How this language actually wants it written.

"""The formalism with its preconditions enforced.

The literal version accepted all of these without complaint:

  * a transition row summing to 0.97, which converges cleanly to the
    value of a process that leaks probability;
  * a discount of exactly 1, where the backup is not a contraction and
    every guarantee silently stops applying;
  * a state with no available action, where the max is over an empty
    set;
  * a kernel, reward and policy whose shapes disagree, which indexes
    into whatever happens to be there.

Each is checked once, at construction. That placement is the point: an
MDP is a contract, and a contract validated per call is one that gets
validated in three places and skipped in the fourth.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import NamedTuple

import numpy as np
import numpy.typing as npt

FloatArray = npt.NDArray[np.float64]
IntArray = npt.NDArray[np.int64]

# Rows may drift by this much from summing to one before it is an error
# rather than floating-point noise.
KERNEL_TOLERANCE = 1e-9


class MdpError(Exception):
    """Base for every violated precondition of the formalism."""


class MalformedKernel(MdpError):
    """A transition row does not describe a distribution.

    Worth its own type because the failure is silent: probability that
    leaks out of a row is never redistributed and never reported, and
    the fixed point that results is the value function of a process
    that quietly loses mass.
    """


class InvalidDiscount(MdpError):
    """Gamma outside [0, 1), where the contraction does not hold."""


class NoAvailableAction(MdpError):
    """A state with an empty action set; the max has nothing to range over."""


class SingularEvaluation(MdpError):
    """(I - gamma P_pi) is not invertible.

    This is not a numerical difficulty to work around. At gamma of one
    with a policy that never terminates, the expectation equation has no
    solution, and the right response is to fix the model.
    """


@dataclass(frozen=True)
class Discount:
    """The discount, with the horizon it actually specifies.

    A value object rather than a float because it is routinely treated
    as a tuning knob, and it is not one: changing it changes the problem
    being solved and the cost of solving it at the same time.
    """

    gamma: float

    def __post_init__(self) -> None:
        if not 0.0 <= self.gamma < 1.0:
            raise InvalidDiscount(
                f"gamma must lie in [0, 1); got {self.gamma}. At exactly 1 the Bellman "
                "backup is not a contraction, so existence, uniqueness and the "
                "convergence rate all stop applying"
            )

    @property
    def effective_horizon(self) -> float:
        """1/(1-gamma) — roughly how many steps this looks ahead."""
        return 1.0 / (1.0 - self.gamma)

    def sweeps_for_tolerance(self, tolerance: float, initial_gap: float = 1.0) -> int:
        """How many backups a given accuracy needs, from the contraction.

        Computable in advance, which makes the cost of a long horizon a
        planning input rather than a surprise: at gamma 0.999 this is
        roughly a thousand times the count at 0.9.
        """
        if tolerance <= 0.0:
            raise ValueError(f"tolerance must be positive, got {tolerance}")
        if self.gamma == 0.0:
            return 1
        return int(np.ceil(np.log(tolerance / initial_gap) / np.log(self.gamma)))


@dataclass(frozen=True)
class MarkovDecisionProcess:
    """States, actions, dynamics, reward — validated once.

    Frozen because an MDP is the problem statement. Mutating the kernel
    after a solve leaves a value function that answers a question no
    longer being asked, and nothing downstream would notice.
    """

    kernel: FloatArray      # (states, actions, states)
    reward: FloatArray      # (states, actions)
    available: npt.NDArray[np.bool_]  # (states, actions)

    def __post_init__(self) -> None:
        if self.kernel.ndim != 3:
            raise MalformedKernel(f"kernel must be (S, A, S); got shape {self.kernel.shape}")

        n_states, n_actions, n_next = self.kernel.shape
        if n_states != n_next:
            raise MalformedKernel(
                f"kernel maps {n_states} states to {n_next}; a transition kernel is square"
            )
        if self.reward.shape != (n_states, n_actions):
            raise MdpError(
                f"reward shape {self.reward.shape} does not match kernel ({n_states}, {n_actions})"
            )
        if self.available.shape != (n_states, n_actions):
            raise MdpError(
                f"availability shape {self.available.shape} does not match the reward"
            )

        if not np.all(np.isfinite(self.reward[self.available])):
            raise MdpError("reward contains a non-finite value on an available action")
        if np.any(self.kernel < 0.0):
            raise MalformedKernel("kernel contains a negative probability")

        # The check that matters most, and the one nothing else catches.
        row_sums = self.kernel.sum(axis=2)
        offenders = np.argwhere(np.abs(row_sums[self.available] - 1.0) > KERNEL_TOLERANCE)
        if offenders.size > 0:
            worst = float(np.max(np.abs(row_sums[self.available] - 1.0)))
            raise MalformedKernel(
                f"a transition row departs from 1 by {worst:.3e}; probability that leaks "
                "is never redistributed, and the fixed point becomes the value of a "
                "process that loses mass"
            )

        empty = np.flatnonzero(~self.available.any(axis=1))
        if empty.size > 0:
            raise NoAvailableAction(
                f"states {empty.tolist()} have no available action; the max in the "
                "optimality equation ranges over an empty set"
            )

    @property
    def n_states(self) -> int:
        return int(self.kernel.shape[0])

    @property
    def n_actions(self) -> int:
        return int(self.kernel.shape[1])

    def action_values(self, values: FloatArray, discount: Discount) -> FloatArray:
        """Q(s, a) for every pair, with unavailable actions masked out.

        Masked rather than dropped so the array shape is constant, and
        masked to negative infinity rather than to a large negative
        number, which would be an exchange rate instead of a prohibition.
        """
        expected = self.kernel @ values
        scored = self.reward + discount.gamma * expected
        return np.where(self.available, scored, -np.inf)


class SolveResult(NamedTuple):
    """A value function, with how much to trust it.

    The bound is free — it falls straight out of the contraction — and
    reporting a value function without it is reporting a number with an
    unstated accuracy.
    """

    values: FloatArray
    sweeps: int
    final_gap: float
    error_bound: float
    converged: bool


def value_iteration(
    mdp: MarkovDecisionProcess,
    discount: Discount,
    tolerance: float = 1e-10,
    max_sweeps: int = 100_000,
) -> SolveResult:
    """Iterate the optimality operator, with a real error bound.

    Guard clauses first, then the loop: the interesting path is the
    iteration and everything else is an early exit.
    """
    if tolerance <= 0.0:
        raise ValueError(f"tolerance must be positive, got {tolerance}")

    values = np.zeros(mdp.n_states, dtype=np.float64)
    gap = np.inf

    for sweep in range(1, max_sweeps + 1):
        updated = mdp.action_values(values, discount).max(axis=1)
        gap = float(np.max(np.abs(updated - values)))
        values = updated

        if gap < tolerance:
            return SolveResult(
                values=values,
                sweeps=sweep,
                final_gap=gap,
                error_bound=discount.gamma * gap / (1.0 - discount.gamma),
                converged=True,
            )

    return SolveResult(
        values=values,
        sweeps=max_sweeps,
        final_gap=gap,
        error_bound=discount.gamma * gap / (1.0 - discount.gamma),
        converged=False,
    )


def evaluate_policy_exactly(
    mdp: MarkovDecisionProcess, policy: FloatArray, discount: Discount
) -> FloatArray:
    """V^pi = (I - gamma P_pi)^-1 R_pi, solved rather than iterated.

    The structural point of the whole formalism: with the max removed
    the equation is linear, and a linear system is solved rather than
    approached. solve() rather than inv(), which is both stabler and the
    idiomatic way to say "I want x, not the inverse".
    """
    if policy.shape != (mdp.n_states, mdp.n_actions):
        raise MdpError(f"policy shape {policy.shape} does not match the MDP")
    if not np.allclose(policy.sum(axis=1), 1.0, atol=KERNEL_TOLERANCE):
        raise MdpError("policy rows must be distributions over actions")
    if np.any(policy[~mdp.available] > 0.0):
        raise MdpError("policy places mass on an unavailable action")

    transition = np.einsum("sa,sat->st", policy, mdp.kernel)
    expected_reward = np.einsum("sa,sa->s", policy, mdp.reward)
    system = np.eye(mdp.n_states) - discount.gamma * transition

    try:
        return np.linalg.solve(system, expected_reward)
    except np.linalg.LinAlgError as error:
        raise SingularEvaluation(
            "(I - gamma P_pi) is singular; at gamma near 1 with a policy that never "
            "terminates the expectation equation has no solution, and the model is "
            "what needs fixing"
        ) from error


def greedy_policy(
    mdp: MarkovDecisionProcess, values: FloatArray, discount: Discount
) -> IntArray:
    """Act greedily, breaking ties by lowest index.

    Tie-breaking is a policy decision rather than an implementation
    detail: exact ties are common in structured problems, and an
    unspecified rule lets a re-solve return a different policy with an
    identical value, which reads as instability and is not.
    """
    return np.argmax(mdp.action_values(values, discount), axis=1).astype(np.int64)


class ContractionReport(NamedTuple):
    gap_before: float
    gap_after: float
    observed_modulus: float
    gamma: float
    holds: bool


def contraction_report(
    mdp: MarkovDecisionProcess, discount: Discount, first: FloatArray, second: FloatArray
) -> ContractionReport:
    """The inequality everything rests on, measured rather than assumed.

        ||T V - T U||_inf  <=  gamma * ||V - U||_inf

    A cheap assertion to keep in a test suite: if it fails, the kernel
    or the discount is malformed in a way the constructor missed, and
    every guarantee downstream is void.
    """
    before = float(np.max(np.abs(first - second)))
    after = float(
        np.max(
            np.abs(
                mdp.action_values(first, discount).max(axis=1)
                - mdp.action_values(second, discount).max(axis=1)
            )
        )
    )
    return ContractionReport(
        gap_before=before,
        gap_after=after,
        observed_modulus=after / before if before > 0.0 else 0.0,
        gamma=discount.gamma,
        holds=after <= discount.gamma * before + 1e-9,
    )


def policy_gap(
    mdp: MarkovDecisionProcess, policy: FloatArray, optimal_values: FloatArray, discount: Discount
) -> float:
    """How much a policy actually loses, in sup-norm.

    Compare policies by their exact value, never by the value functions
    that produced them: two value functions can differ substantially and
    induce the same greedy policy, and two that differ slightly can
    induce different ones. The policy is the artefact; the value
    function is scaffolding.
    """
    return float(np.max(np.abs(optimal_values - evaluate_policy_exactly(mdp, policy, discount))))

What changed: The MDP stops being four loose nested lists and becomes a validated object, because every guarantee in this entry is conditional on properties nothing in the literal version checked: rows of the kernel summing to one, a discount strictly below one, rewards that are finite, and shapes that agree across the kernel, the reward and the policy. A kernel that leaks probability converges perfectly to the value of a process that loses mass, which is the exact shape of failure this formalism specializes in — clean numbers describing the wrong problem — so the checks happen once at construction rather than being implied. Every recoverable failure becomes a specific exception naming the values that caused it, including the two that are genuinely unrecoverable rather than merely wrong: a discount of one with a policy that never terminates, where the linear system is singular and the equation has no solution, and a state with no available action, where the max is over an empty set. The discount becomes a frozen value object that also reports its own effective horizon, so the number people treat as a tuning knob announces the horizon it actually specifies. Value iteration returns a result object carrying the sweep count, the final gap and the contraction error bound rather than a bare list, because the bound is free, computable and the only honest statement about how converged the answer is. And the diagnostics that catch a misspecified model are functions rather than advice: a measured contraction modulus, and an exact policy evaluation that lets a derived policy be compared by its value rather than by the value function that produced it.

Explicit type hints on every public signatureDataclasses or NamedTuples over ad-hoc dictsRaise specific exceptions, never bare exceptGuard clauses over nested conditionalsNo mutable default arguments

One backup becomes a single (S, A, S) times (S,) contraction plus a row-wise max, so the per-sweep cost is unchanged asymptotically but the Python loop over states is gone; exact evaluation is one O(|S|³) solve. Illustrative, not a measured benchmark: the substantive change is that a leaking kernel, a discount of one, a state with no action and a policy placing mass on an unavailable one are now failures raised at construction, where the literal version converged cleanly on every one of them and reported a number.

3. Optimized

Faster, and the justification for each change.

"""The Bellman backup, shaped for a kernel that is mostly zeros.

A real MDP has a handful of reachable successors per state-action pair.
A dense (S, A, S) kernel is therefore almost entirely zeros, and a dense
backup spends nearly all of its time multiplying by them. Flattening
into one CSR matrix over state-action pairs turns the expectation into a
single sparse matrix-vector product whose cost is the number of
transitions that exist.

What this does not change: the sweep count still comes from gamma, so a
faster sweep does not shorten a long horizon. It makes each of the
thousand sweeps cheap; it does not make there be fewer of them.
"""

from __future__ import annotations

import heapq
from dataclasses import dataclass

import numpy as np
import numpy.typing as npt
from scipy import sparse

FloatArray = npt.NDArray[np.float64]
IndexArray = npt.NDArray[np.int32]


@dataclass
class SparseMdp:
    """Transitions as one CSR matrix over flattened (state, action) rows.

    transitions is (S·A, S): row s*A + a holds the successor
    distribution for that pair. One sparse matvec then computes the
    expectation for every pair simultaneously, which is the whole
    reason for the flattening.

    The layout is immutable by construction — CSR indices encode the
    sparsity pattern — so a kernel re-estimated on a schedule means a
    rebuild. That is the right trade for a solver and the wrong one for
    a model that is being edited.
    """

    transitions: sparse.csr_matrix
    reward: FloatArray            # (S·A,), flattened to match
    available: npt.NDArray[np.bool_]  # (S·A,)
    n_states: int
    n_actions: int

    @classmethod
    def from_dense(
        cls,
        kernel: FloatArray,
        reward: FloatArray,
        available: npt.NDArray[np.bool_],
    ) -> SparseMdp:
        n_states, n_actions, _ = kernel.shape
        flattened = kernel.reshape(n_states * n_actions, n_states)

        return cls(
            transitions=sparse.csr_matrix(flattened, dtype=np.float64),
            reward=np.ascontiguousarray(reward.reshape(-1), dtype=np.float64),
            available=np.ascontiguousarray(available.reshape(-1)),
            n_states=n_states,
            n_actions=n_actions,
        )

    @property
    def density(self) -> float:
        """What fraction of the kernel is actually non-zero.

        Worth reporting rather than assuming: if this is near one the
        sparse layout is pure overhead and the dense backup was
        correct. It is the number that decides which implementation is
        appropriate, and it is cheap.
        """
        return self.transitions.nnz / (self.transitions.shape[0] * self.n_states)


class BackupWorkspace:
    """Every buffer a sweep needs, allocated once.

    A solver running ten thousand sweeps at a high discount allocates
    nothing after construction. The buffers are not reentrant, which is
    the price: two solves cannot share a workspace.
    """

    def __init__(self, mdp: SparseMdp) -> None:
        self.pairs = np.empty(mdp.n_states * mdp.n_actions, dtype=np.float64)
        self.updated = np.empty(mdp.n_states, dtype=np.float64)
        self.residual = np.empty(mdp.n_states, dtype=np.float64)


def fused_optimality_backup(
    mdp: SparseMdp, values: FloatArray, gamma: float, workspace: BackupWorkspace
) -> FloatArray:
    """One backup: sparse matvec, mask and max, in a single pass.

    The (S·A) action-value array is written into preallocated scratch
    rather than returned, so nothing is materialized per sweep. The
    availability mask is applied as negative infinity rather than as a
    large negative reward, which keeps a prohibition a prohibition
    instead of an exchange rate the optimizer can trade against.
    """
    # Expectation for every (state, action) pair at once. Cost is the
    # number of transitions that exist, not the square of the state
    # count -- which is the entire point of the layout.
    np.multiply(mdp.transitions.dot(values), gamma, out=workspace.pairs)
    np.add(workspace.pairs, mdp.reward, out=workspace.pairs)
    np.copyto(workspace.pairs, -np.inf, where=~mdp.available)

    # Row-wise max over actions, into the preallocated value buffer.
    np.max(
        workspace.pairs.reshape(mdp.n_states, mdp.n_actions), axis=1, out=workspace.updated
    )
    return workspace.updated


def value_iteration(
    mdp: SparseMdp, gamma: float, tolerance: float = 1e-10, max_sweeps: int = 100_000
) -> dict[str, object]:
    """Jacobi iteration with the contraction's error bound.

    Sweep count is set by gamma and the tolerance and not by anything
    done here. That is worth restating because it is the thing a faster
    backup cannot fix: at gamma 0.999 the answer needs roughly a
    thousand times the sweeps of gamma 0.9, and each of them is now
    cheap.
    """
    workspace = BackupWorkspace(mdp)
    values = np.zeros(mdp.n_states, dtype=np.float64)
    gap = np.inf

    for sweep in range(1, max_sweeps + 1):
        updated = fused_optimality_backup(mdp, values, gamma, workspace)
        np.subtract(updated, values, out=workspace.residual)
        gap = float(np.max(np.abs(workspace.residual)))
        values = values.copy() if sweep == 1 else values
        np.copyto(values, updated)

        if gap < tolerance:
            break

    return {
        "values": values,
        "sweeps": sweep,
        "final_gap": gap,
        "error_bound": gamma * gap / (1.0 - gamma),
        "converged": gap < tolerance,
    }


def gauss_seidel_sweep(
    mdp: SparseMdp, values: FloatArray, gamma: float, order: IndexArray
) -> float:
    """In-place backup reading its own updates within the sweep.

    Jacobi computes every new value from the old array; Gauss-Seidel
    reads whatever is current, so information propagates across the
    state space within a single pass instead of one state per sweep.
    On most structured problems that is a meaningful reduction in sweep
    count for no extra arithmetic.

    What it costs is the clean operator semantics: this is no longer an
    application of T to a value function, so the contraction bound needs
    restating and the ordering now affects the trajectory. The fixed
    point is the same; the path to it is not.
    """
    largest_change = 0.0
    indptr = mdp.transitions.indptr
    indices = mdp.transitions.indices
    data = mdp.transitions.data

    for state in order:
        best = -np.inf
        base = int(state) * mdp.n_actions

        for action in range(mdp.n_actions):
            row = base + action
            if not mdp.available[row]:
                continue

            expectation = 0.0
            for position in range(indptr[row], indptr[row + 1]):
                expectation += data[position] * values[indices[position]]

            candidate = mdp.reward[row] + gamma * expectation
            if candidate > best:
                best = candidate

        change = abs(best - values[state])
        if change > largest_change:
            largest_change = change
        values[state] = best

    return largest_change


def prioritized_sweeping(
    mdp: SparseMdp,
    gamma: float,
    predecessors: list[list[int]],
    tolerance: float = 1e-10,
    max_updates: int = 1_000_000,
) -> dict[str, object]:
    """Update the states that are still wrong, in residual order.

    A uniform sweep spends the same effort on a state whose value has
    converged as on one that has not. Prioritized sweeping keeps a queue
    ordered by Bellman residual and pushes a state's predecessors
    whenever its value moves, so work follows the information.

    Whether it pays is a property of the problem: when the residual is
    concentrated -- a goal-directed problem where value propagates
    backwards from a terminal state -- it is a large win; on a uniformly
    wrong value function the heap is pure overhead against a vectorized
    sweep. The density and residual distribution decide, not taste.
    """
    values = np.zeros(mdp.n_states, dtype=np.float64)
    queue: list[tuple[float, int]] = []
    queued: set[int] = set()

    for state in range(mdp.n_states):
        heapq.heappush(queue, (-abs(mdp.reward[state * mdp.n_actions]), state))
        queued.add(state)

    updates = 0
    while queue and updates < max_updates:
        priority, state = heapq.heappop(queue)
        queued.discard(state)

        if -priority < tolerance:
            continue

        previous = values[state]
        gauss_seidel_sweep(mdp, values, gamma, np.array([state], dtype=np.int32))
        updates += 1

        change = abs(values[state] - previous)
        if change < tolerance:
            continue

        # The change propagates backwards, so predecessors are the
        # states whose values are now stale.
        for predecessor in predecessors[state]:
            if predecessor in queued:
                continue
            heapq.heappush(queue, (-gamma * change, predecessor))
            queued.add(predecessor)

    return {"values": values, "updates": updates}


def sweeps_needed(gamma: float, tolerance: float, initial_gap: float = 1.0) -> int:
    """How many backups the tolerance needs, before running any.

    Straight from the contraction, and the honest way to size a job: the
    cost of a long horizon is knowable in advance rather than discovered
    after an hour. Nothing in this file changes this number.
    """
    if not 0.0 <= gamma < 1.0:
        raise ValueError(f"gamma must lie in [0, 1); got {gamma}")
    if gamma == 0.0:
        return 1
    return int(np.ceil(np.log(tolerance / initial_gap) / np.log(gamma)))

What changed: The dense kernel is the wrong data structure and replacing it is worth more than every other change here combined: a real MDP has a handful of reachable successors per state-action pair, so a dense (S, A, S) array is almost entirely zeros and a dense backup spends its time multiplying by them. Flattening the pairs into one CSR matrix turns the expectation into a single sparse matrix-vector product whose cost is the number of non-zero transitions rather than the square of the state count, which is the difference between a problem that fits and one that does not. On top of that the backup is fused: the reward add, the discounted expectation, the availability mask and the per-state max over actions run as one pass into preallocated buffers, so the (S·A) intermediate that the previous stage materialized on every sweep is never written. Gauss-Seidel ordering replaces the Jacobi sweep — reading updated values within the same pass rather than from a copy — which converges in noticeably fewer sweeps on most structured problems at no cost beyond giving up the clean operator semantics. Where the tolerance is loose, prioritized sweeping reorders the work by Bellman residual so the states that are still wrong are updated first instead of every state being visited equally. Everything is float64 and C-contiguous end to end, since the accumulated error over thousands of sweeps at a high discount is large enough to matter and a silent dtype promotion mid-expression would copy the whole kernel.

  • Vectorize to NumPy/BLAS (@, np.dot, einsum)

    The expectation over successors becomes one sparse matrix-vector product over all state-action pairs at once instead of a loop or a dense contraction

    Trade-off: The CSR layout must be built up front and is immutable, so a kernel that changes — an estimated model refreshed on a schedule — means a rebuild rather than an edit

  • Fuse adjacent operations so intermediates are never materialized

    Reward, discounted expectation, availability mask and the per-state max run in one pass, so the (S·A) action-value array is never written

    Trade-off: The fused form hides the action values, which are exactly what you want when asking why a particular action was chosen

  • Pre-allocate output arrays and use in-place operations

    Value, scratch and residual buffers are allocated once, so ten thousand sweeps at a high discount allocate nothing

    Trade-off: The buffers are not reentrant, so two solves cannot share a workspace and a parallel sweep needs its own copy

  • Ensure contiguous memory layout and a single dtype

    Keeping the CSR data, the reward and the values all float64 and C-contiguous avoids a silent promotion that would copy the whole kernel mid-expression

    Trade-off: float64 doubles the memory of the largest array in the system, which is the binding constraint at the state counts where sparsity was the point

  • Eliminate Python-level loops over samples

    The per-state Gauss-Seidel pass is the one remaining loop, and prioritized sweeping shrinks it to the states whose residual is still large

    Trade-off: The priority queue costs a log factor per update and only pays when the residual is concentrated; on a uniformly-wrong value function it is pure overhead

One sparse backup is O(nnz) where nnz is the number of transitions that exist, against O(|S|²·|A|) dense — on a kernel with a handful of successors per pair that is the difference between linear and quadratic in the state count. Sweep count remains log(tolerance)/log(gamma), unchanged by anything here. Illustrative, not a measured benchmark: the number worth checking first is the kernel density, since above roughly ten percent non-zero the sparse layout costs more in indirection than it saves in arithmetic and the dense backup is the right implementation.