Skip to content

Tree search

BetaWorks, but under active development — APIs and results may shift. In production use by all three zero-series agents. One batching mode is known-biased and gated off — see below.

AlphaZero, MuZero and EfficientZero V2 differ in what they learn. They barely differ in how they search. noeira.planners.tree_search is the search, factored out of all three.

from noeira.planners.tree_search import (
GenericCPUMCTS, GenericGPUMCTS, GumbelGPUMCTS, SampledGumbelGPUMCTS,
)

MCTS builds a tree by repeatedly descending to a leaf, expanding it, and backing the resulting value up the path it came down. What varies between agents is where the nodes come from:

observationrealhs⁰s¹s²ggf → p, vh · representationobs → s⁰g · dynamics(s, a) → s′, rf · predictions → policy, valueEvery node below the root isimagined — the environment isnever queried during search.
real observationlatent transition (g)evaluation (f)

Three traits carry everything the search needs — Representation, Dynamics and Prediction — and the adapter that implements them lives next to the agent that owns the networks. The contract is deliberately plain: List[Float64] in, List[Float64] out, with probabilities already softmaxed and values already decoded.

That last part matters more than it reads. Categorical decoding, symlog transforms and hidden-state scaling all belong to the adapter, not to the search. MCTS never learns that MuZero encodes value over bins or that its hidden states are min-max scaled, which is exactly why AlphaZero — whose “representation” is the identity and whose “dynamics” is the game’s own step function — implements the same three traits without contortion.

The parts that genuinely differ between agents are compile-time parameters, not runtime flags:

TraitOptionsChooses
SearchModeLearnedDynamics, TrueGameRuleslatent tree vs. real game states in nodes
PUCTFormulaMuZeroPUCT, AlphaGoPUCT, UCB1Formulathe exploration term
ExplorationNoiseDirichletNoise, EpsilonNoise, NoNoiseroot exploration
PlayerModeSinglePlayer, SelfPlaywhether values negate down the tree
BackupModeNStepBootstrap, MonteCarloReturn, LambdaReturnhow returns are formed
HiddenScalingMinMaxScale, NoScalelatent normalization

These are not flag structs — each carries the actual computation. MuZeroPUCT supplies c(s) = log((1 + N(s) + c_base) / c_base) + c_init, which grows the exploration constant as the tree does; AlphaGoPUCT supplies a constant c_puct. The formula is inlined into the selection loop at compile time, so choosing between them costs nothing at runtime.

comptime Search = GenericCPUMCTS[
ACTION_DIM, LATENT_DIM, NUM_SIMULATIONS, MAX_NODES,
MuZeroPUCT, DirichletNoise, SinglePlayer,
]

Q-values are min-max normalized to [0, 1] before the PUCT prior term. That is always on, and it is what keeps a single exploration constant sensible across environments whose reward scales differ by orders of magnitude.

The PUCT selection rule has a known weakness at small simulation budgets: it decides where to look next using statistics that are themselves still noisy, and with a few dozen simulations it can commit to a bad action early. Gumbel MuZero replaces it with a scheme that spends a fixed budget by construction.

Sequential halving at the rootGumbel-Top-k samples K candidates; the budget is then divided, not steered.K root candidatesvisits eachphase 1N / (3 · 8)phase 2N / (3 · 4)phase 3N / (3 · 2)chosen↓Selection below the root is a visit-balance rule, not PUCT.
still activeeliminatedsurvivor
improved policy, read off the rootπ̂ = softmax( logits + σ( completed_Q ) )

Gumbel-Top-k samples K candidates at the root. Sequential halving then runs log2(K) phases, giving every surviving candidate the same number of visits within a phase and eliminating the worst half between phases. Selection below the root uses a deterministic visit-balance rule — argmax_a [π̂(a) − N(s,a) / (1 + ΣN(s,b))] — rather than PUCT.

The reason to care beyond sample efficiency is that sequential halving is order-independent. A phase’s visits can be issued in any order without changing the result, which is what makes the batched GPU path safe here.

This is the kind of bug that a “does it train?” test never catches — the agent still learns, just from slightly wrong targets. It was found by comparing the GPU search against the CPU one (tests/deep_agents/test_az_search_gpu_batched_bias.mojo), which is an argument for keeping a slow reference implementation around long after it stops being the production path.

SampledGumbelGPUMCTS is the variant EfficientZero V2 uses for continuous control. The tree is keyed by candidate slot rather than by action index, and each slot stores the actual ACT_DIM vector it represents. K_ROOT candidates are sampled at the root and K_NON_ROOT at every node below.

Root sampling has two modes, chosen at compile time by N_POLICY_AT_ROOT:

  • Magnified (N_POLICY_AT_ROOT == K_ROOT) — half the candidates from N(μ, σ), half from N(μ, std_mag · σ)
  • DMC-style (N_POLICY_AT_ROOT < K_ROOT) — the first slice from N(μ, σ), the rest uniform over ±max_action

There is no improved-policy distribution to extract, because the action space is continuous. The orchestrator writes the chosen action directly, plus root visit counts for diagnostics and for the policy-weighted Q used in the value target.

ActionsSelectionBatched GPU
GenericCPUMCTSdiscretePUCT—
GenericGPUMCTSdiscretePUCTonly at BATCH_SIMS = 1
GumbelGPUMCTSdiscretesequential halvingyes
SampledGumbelGPUMCTScontinuoussequential halvingyes

GumbelGPUMCTS also drives the AlphaZero path when STATE_SIZE > 0: tree nodes then carry true game-state payloads and expansion calls the environment’s step_gpu instead of a dynamics network.

The two stub models in noeira/planners/testing/ are what make the search testable in isolation:

Terminal window
pixi run mojo run -I . tests/planners/tree_search/test_mcts_bandit.mojo
pixi run mojo run -I . tests/planners/tree_search/test_mcts_value_tree.mojo

TwoArmBandit pins down that the search concentrates visits on the better arm; KnownValueTree is a tree whose values are fixed by construction, so the backup arithmetic can be checked against a number rather than against a trend. Neither involves a network.