Skip to content

Scheduler

The search engine beneath Search: the shared tree, branching gates, and allocation sessions.

Keep the evidence used to allocate continuations at one checkpoint: completed and pending purchases, plus running statistics of completed suffix returns. Evidence counts can differ from purchase counts because earlier rollouts also supply observations.

from focus.allocation import Observations

Definition

@dataclass
class Observations

Fields

m_done: int = 0
m_pending: int = 0
returns: RunningStats = field(default_factory=RunningStats)
@property
def m_alloc(self) -> int
@property
def n_eff(self) -> int

Track completed and pending continuations at each node. Allocation sessions for the same tree share these counts.

from focus.scheduler import AllocationLedger

Definition

class AllocationLedger[P: Payload]
def __init__(self, *, observations: dict[int, Observations] | None=None) -> None
def bind(self, view: SchedulerView[P]) -> None
def counts(self, node: Node[P]) -> Observations
def record(self, parent: Node[P], chain: list[Node[P]]) -> None

Hook for recording the evidence from a completed expansion. The base implementation does nothing; RolloutLedger overrides it to record returns from continuation suffixes.

Choose checkpoints and track their continuations within one pass. Implement choose() for a custom selection rule; the shared lifecycle handles completion, failure, and cancellation.

from focus.scheduler import AllocationSession

Definition

class AllocationSession[P: Payload](ABC)
def __init__(self, ledger: AllocationLedger[P] | None=None) -> None
def start(self, view: SchedulerView[P]) -> None
def stop(self) -> None

Required implementation.

@abstractmethod
def choose(self, view: SchedulerView[P]) -> Node[P] | None
@final
def select(self, view: SchedulerView[P]) -> Reservation[P] | None
def active(self, reservation: Reservation[P]) -> bool
@final
def complete(self, reservation: Reservation[P], state: SchedulerState[P], chain: list[Node[P]]) -> None
@final
def fail(self, reservation: Reservation[P], error: BaseException) -> None
@final
def cancel(self, reservation: Reservation[P]) -> None
def selected(self, reservation: Reservation[P], view: SchedulerView[P]) -> None
def completed(self, reservation: Reservation[P], state: SchedulerState[P], chain: list[Node[P]]) -> None
def released(self, reservation: Reservation[P], error: BaseException | None) -> None

Stop launching continuations when the configured turn, rollout, node, or goal limit is reached.

from focus.scheduler import Budget

Definition

class Budget[P: Payload](Termination[P])
def __init__(self, max_rollouts: int | None=None, *, max_turns: int | None=None, max_nodes: int | None=None, goal: Callable[[SchedulerView[P]], bool] | None=None) -> None
@staticmethod
def turns_spent(view: SchedulerView[P]) -> float

Turns realized, plus one per failure.

A dead expansion records no turns — it produced no payload to read them off — so a turn cap counting successes alone would never deplete against an expander that always fails, and the loop would re-select and re-fail forever. Charging a turn for the attempt is the same protection max_rollouts gets by counting failures, and it is a floor: a rollout that died partway spent at least that.

def remaining(self, view: SchedulerView[P]) -> int | None

Limit simultaneous continuations from each checkpoint while allowing more continuations after earlier ones finish.

from focus.scheduler import ConcurrencyGating

gating = ConcurrencyGating(limit=2)

A checkpoint can have two active continuations. A completed continuation releases its slot for another one.

Definition

class ConcurrencyGating(GatingPolicy)
def __init__(self, limit: int=1) -> None
def gate(self, node: Node[Any]) -> Gate

Define how one selected node produces a chain of child payloads. This is the scheduler’s interface to task execution.

from focus.scheduler import Expander

Definition

class Expander[P](ABC)

Required implementation.

@abstractmethod
async def expand(self, payload: P) -> tuple[list[P], Mapping[str, object]]

The children, plus metadata to record on each node they become.

The metadata rides back with the payloads rather than being written by the expander itself, because the payloads exist before the nodes do. An expander with nothing to record answers {}.

Maintain the state for one FIFO pass and choose the first eligible checkpoint from the frontier.

from focus.scheduler import FifoSession

Definition

class FifoSession[P: Payload](AllocationSession[P])
def choose(self, view: SchedulerView[P]) -> Node[P] | None

Reserve and release branch capacity for a node without blocking the scheduler.

from focus.scheduler import Gate

Definition

class Gate
def __init__(self, *, max_children: int | None=None, max_pending: int | None=None) -> None
@property
def in_use(self) -> int
def available(self, node: Node[Any]) -> bool

Whether a branch may launch from node right now.

def reserve(self) -> None

Claim a slot (on launch).

def release(self) -> None

Free a slot (on completion).

Decide how many expansions a checkpoint may launch. Implement gate to vary branch limits by checkpoint or depth.

from focus.scheduler import GatingPolicy

Definition

class GatingPolicy(ABC)

Required implementation.

@abstractmethod
def gate(self, node: Node[Any]) -> Gate

Hold one tree payload, its parent and children, critic predictions, and scheduling metadata.

from focus.scheduler import Node

Definition

@dataclass
class Node[P: Payload]

Fields

id: int
parent_id: int | None
depth: int
payload: P
children: list[int] = field(default_factory=list)
gate: Gate = field(init=False)
critic: float | None = None
value_context: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)

Batch a critic’s predictions for new checkpoints through the execution services. The collector creates one; a search scores each new checkpoint before it can be selected.

from focus.scheduler import NodeScorer

scorer = NodeScorer(critic, services=agent.services)

Predictions are stored on each node as its critic prior, separate from observed returns. The critic’s context() renders each checkpoint as input.

Definition

class NodeScorer[P: Payload]
def __init__(self, critic: Critic, *, services: ExecutionServices | None=None) -> None
async def score(self, payloads: Sequence[P]) -> list[ValuePrediction]

Predict values for payloads in one batched call, preserving order.

A critic that answers with the wrong number of predictions raises here, inside the callers’ guards, so the fault costs an estimate rather than the run.

def write(self, nodes: Sequence[Node[P]], scores: Sequence[ValuePrediction]) -> None

File scores on nodes, pairwise.

The node-metadata key recording which search phase created a node. Preparation trains only on edges created by search.

from focus.scheduler import PASS_KEY

Definition

PASS_KEY = 'pass'

Define the state properties required by the tree scheduler. RolloutState supplies the harness implementation.

from focus.scheduler import Payload

Definition

class Payload(Protocol)
@property
def forkable(self) -> bool

May the scheduler branch (expand) at this node.

@property
def done(self) -> bool

Whether this payload is terminal.

@property
def reward(self) -> float

The default ranking score (used by SchedulerState.best).

An allocation session that ranks checkpoints. Implement score(); the session selects the eligible checkpoint with the highest score. Most extensions implement Priority instead, which builds this session for you.

from focus.scheduler import RankedSession

Definition

class RankedSession[P: Payload](AllocationSession[P], ABC)

Required implementation.

@abstractmethod
def score(self, view: SchedulerView[P], node: Node[P]) -> float
def eligible(self, score: float) -> bool
def choose(self, view: SchedulerView[P]) -> Node[P] | None

Identify one launched continuation. Two reservations from the same checkpoint track two separate continuations still in progress.

from focus.scheduler import Reservation

Definition

@dataclass(frozen=True, eq=False)
class Reservation[P: Payload]

Fields

node: Node[P]

Fork an isolated rollout, run it forward, and return its checkpoint chain to the scheduler.

from focus.scheduler import RolloutExpander

Definition

class RolloutExpander[S](Expander[RolloutState[S]])
def __init__(self, agent: Agent[S, Any], *, sampling_params: SamplingParams | None=None, annotation: Mapping[str, object] | None=None) -> None
async def expand(self, payload: RolloutState[S]) -> tuple[list[RolloutState[S]], Mapping[str, object]]
async def start(self, prompt: str, *, workspace: Workspace | None=None) -> RolloutState[S]

Convenience: build the search root from a prompt (keeps the Agent here).

Coordinate concurrent expansions, allocation sessions, branching gates, and critic passes over a tree.

from focus.scheduler import Scheduler

Definition

class Scheduler[P: Payload]
def __init__(self, *, expander: Expander[P], allocation: AllocationSession[P] | None=None, termination: Termination[P] | None=None, gating: GatingPolicy | None=None, scorer: NodeScorer[P] | None=None, max_concurrency: int=8) -> None
async def run(self, root: P) -> SchedulerState[P]

Search from a root payload until termination, and return the tree it grew.

async def run_on(self, state: SchedulerState[P]) -> SchedulerState[P]

Search over an existing (possibly shared) tree until termination.

Lets other code run concurrently over the same state under state.lock. Gating comes from state.gating (set when the state was built), not this scheduler’s own default.

Returns the very state it was handed. The tree is the result: its terminals and its best node are questions asked of it (SchedulerState.terminals, SchedulerState.best), and a record that answered them once at return froze them at the moment the run stopped — which is exactly wrong for the caller that runs a second pass over the same tree, as every later search pass does.

Termination ends the launching, not the run: once remaining() is 0 the loop drains — it keeps awaiting and integrating what is already in flight until nothing is, and only then returns (see the module docstring for why, and for the overshoot that buys). The in-flight expansions are cancelled only when the caller cancels, or when something escapes the loop.

Store the search tree and the frontier of nodes currently available for expansion.

from focus.scheduler import SchedulerState

Definition

@dataclass
class SchedulerState[P: Payload]

Fields

nodes: dict[int, Node[P]]
root_id: int
gating: GatingPolicy
frontier: dict[int, int] = field(default_factory=dict)
stats: dict[str, float] = field(default_factory=dict)
failures: list[tuple[int, str]] = field(default_factory=list)
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
_next_id: int = 1
_arrivals: int = 0
@classmethod
def root(cls, payload: P, *, gating: GatingPolicy) -> SchedulerState[P]

A fresh state seeded with the initial payload as the tree root.

def selectable(self, node: Node[P]) -> bool

Whether node may be expanded: forkable, live, and its gate has a slot.

def refresh(self, node_id: int) -> None

Recompute one node’s frontier membership (idempotent, order-preserving).

def add_child(self, parent_id: int, payload: P) -> Node[P]

Attach one payload as a child of parent_id and index it.

def attach_chain(self, parent_id: int, chain: list[P]) -> list[Node[P]]

Link a rollout’s payloads as a descendant line under parent_id.

The first payload becomes a child of parent_id (a new branch); each later payload is a child of the previous (the step line). Returns the new nodes.

def regate(self, gating: GatingPolicy) -> None

Re-mint every gate under a new policy and rebuild the frontier.

Gates are minted once, when a node is added, so a node that filled its discovery width has no slot left — correct while discovery runs, and exactly wrong for a later pass that must launch from those same nodes. This is the whole pass-boundary mechanism the tree needs: one explicit transition, called between passes under lock, rather than per-pass gate bookkeeping smeared through the loop.

def record_failure(self, node_id: int, exc: BaseException) -> None
def terminals(self) -> list[Node[P]]

Every node whose payload ended the episode naturally.

def best(self) -> Node[P] | None

The highest-reward terminal node, or None if nothing finished.

Ranked by the payload’s own reward — the one score every payload has. A caller ranking by anything else (a critic channel, a custom key) is choosing among terminals and can say so in its own terms.

def describe(self, batch: Any=None) -> str

The tree, one node per line, with any actor weights from batch joined.

See focus.export.format_tree for the line format.

Read the rollout tree, its eligible checkpoints, and collection statistics. Priorities and estimators read this view to inspect parent-child relationships.

from focus.scheduler import SchedulerView

view = SchedulerView(tree)
root = view.root
children = view.children(root)

Wrap the tree returned by Collector.search(). Here, children contains the root’s direct branches, while descendants iterates through its whole subtree.

Definition

@dataclass(frozen=True)
class SchedulerView[P: Payload]

Fields

_state: SchedulerState[P]
@property
def stats(self) -> Mapping[str, float]
@property
def failures(self) -> tuple[tuple[int, str], ...]

Return failed expansions as (node_id, repr(exception)) pairs. The returned tuple is a snapshot of the failures recorded so far.

def nodes(self) -> list[Node[P]]

Every node in the tree.

def selectable(self, node: Node[P]) -> bool
@property
def frontier_size(self) -> int
def iter_frontier(self) -> Iterator[Node[P]]

Iterate over eligible nodes without copying the frontier. Hold the tree lock while using this iterator.

def frontier(self) -> list[Node[P]]

Eligible nodes in insertion order: each is live, forkable, and has an available gate slot.

def arrival(self, node: Node[P]) -> int

When node last became selectable, as a position in arrival order.

def node(self, node_id: int) -> Node[P] | None

Look up a node by ID, returning None if it is absent.

@property
def root(self) -> Node[P]

The tree’s root node, which holds the starting state for this prompt.

def children(self, node: Node[P]) -> list[Node[P]]

Return a node’s direct children in the order they were attached.

def terminals(self) -> list[Node[P]]

Return nodes whose environment state has ended the episode.

def path_to(self, node: Node[P]) -> list[Node[P]]

Return the parent chain ending at node, in root-to-node order. For a node in this tree, both root and node are included. A chain disconnected from this tree’s root produces a path that does not begin at the root.

def descendants(self, node: Node[P]) -> Iterator[Node[P]]

Iterate depth-first over all nodes below node, excluding the node itself. The iterator is lazy, so callers can stop as soon as they find a match.

def __len__(self) -> int

Define how many additional continuations the scheduler may launch from its current state.

from focus.scheduler import Termination

class NodeLimit(Termination):
    def __init__(self, limit):
        self.limit = limit

    def remaining(self, view):
        return 0 if len(list(view.nodes())) >= self.limit else 1

termination = NodeLimit(100)

The scheduler can launch one continuation while the tree has fewer than 100 nodes. A continuation can add several nodes, so this is an admission rule rather than an exact final tree-size bound.

Definition

class Termination[P: Payload](ABC)

Required implementation.

@abstractmethod
def remaining(self, view: SchedulerView[P]) -> int | None

Rollouts still launchable now: 0 = stop, positive = cap, None = none.

The scheduler counts in-flight rollouts against a returned cap, so a rollout budget is a hard cap — completed + in-flight never exceeds it.

0 ends the launching, not the run: the scheduler then drains what is already in flight and integrates it normally (finished rollouts are the expensive half, and cancelling them at the budget threw away up to one episode per slot). The first 0 is latched — this is never asked again on that run, so a predicate a drained result would flip back cannot re-open the pass — and the stats a caller reads afterwards include everything the drain integrated.

Cap the total number of direct child branches from each checkpoint, counting completed and in-flight branches together.

from focus.scheduler import WidthGating

gating = WidthGating(width=3)

Each checkpoint gets at most three direct branches. Completed children continue to count against this cap.

Definition

class WidthGating(GatingPolicy)
def __init__(self, width: int=1) -> None
def gate(self, node: Node[Any]) -> Gate

Pair a critic score with the exact serialized context used to compute it.

from focus.scheduler.core.critic import ValuePrediction

Definition

@dataclass(frozen=True)
class ValuePrediction

Fields

score: float
context: str

Compute the per-token proxy -p log(p) from finite token log probabilities.

from focus.scheduler.core import token_entropies

Definition

def token_entropies(logprobs: Iterable[float]) -> list[float]

Name the scheduler statistic counting failed expansions.

from focus.scheduler.core.tree import STAT_FAILURES

Definition

STAT_FAILURES = 'failures'

Name the scheduler statistic counting tree nodes.

from focus.scheduler.core.tree import STAT_NODES

Definition

STAT_NODES = 'nodes'

Name the scheduler statistic counting rollouts.

from focus.scheduler.core.tree import STAT_ROLLOUTS

Definition

STAT_ROLLOUTS = 'rollouts'

Name the scheduler statistic counting critic-scoring failures.

from focus.scheduler.core.tree import STAT_SCORE_FAILURES

Definition

STAT_SCORE_FAILURES = 'score_failures'

Name the scheduler statistic counting task transitions.

from focus.scheduler.core.tree import STAT_TURNS

Definition

STAT_TURNS = 'turns'

The number of task transitions on the edge into a node: the dd in the discount γd\gamma^d that refined values and segment TD advantages apply to a successor’s value.

from focus.scheduler import edge_duration

Definition

def edge_duration(node: Node[AnyRollout], parent: Node[AnyRollout]) -> int

Compute the discounted return along a node’s incoming edge.

from focus.scheduler import edge_return

Definition

def edge_return(node: Node[AnyRollout], parent: Node[AnyRollout], config: RewardConfig) -> float

Enumerate completed trajectories reachable below a checkpoint.

from focus.scheduler import finished_below

Definition

def finished_below(view: SchedulerView[AnyRollout], node: Node[AnyRollout]) -> list[Node[AnyRollout]]

Check whether a completed trajectory passes through a node, including the node itself.

from focus.scheduler import finished_through

Definition

def finished_through(view: SchedulerView[AnyRollout], node: Node[AnyRollout]) -> bool