# Architecture overview
dockyard_rl trains a policy model with GRPO (Group Relative Policy Optimization)
against execution-grounded rewards.
The defining structural choice is that the three responsibilities — training,
generation, and scoring — run as **separate, asynchronous fleets** rather than
phases of one process.
## The three fleets
Each container declares `DOCKYARD_FLEET_ROLE` before `init_ray()`. The cluster
bootstrap (`cluster/bootstrap.py`) reads that role to pick the placement
strategy, resource spec, and NCCL (NVIDIA Collective Communications Library)
topology.
```mermaid
flowchart LR
subgraph TRAINER["Trainer fleet · GPU · SPREAD"]
direction TB
POLICY["Policy workers
DTensor/FSDP2 · JAX/NNX"]
GRPO["GRPO optimizer
advantages · clipped-PG loss"]
POLICY --> GRPO
end
subgraph INFERENCE["Inference fleet · GPU · PACK"]
ENGINE["vLLM async engine
or SGLang · serves rollouts"]
end
subgraph SANDBOX["Sandbox fleet · CPU · SPREAD"]
EXEC["ubuntu-swe task executor
clone repo · apply patch · run tests"]
end
BUFFER[("Replay buffer
async · trajectory-age bounded")]
GRPO -- "1 · weight sync (NCCL collective)" --> ENGINE
ENGINE -- "2 · rollout: code patch / actions" --> EXEC
EXEC -- "3 · verdict: tests pass/fail → reward" --> BUFFER
ENGINE -. "trajectories + logprobs" .-> BUFFER
BUFFER -- "4 · sampled experience" --> POLICY
style TRAINER stroke:#d29922,stroke-width:2px
style INFERENCE stroke:#1f6feb,stroke-width:2px
style SANDBOX stroke:#2da44e,stroke-width:2px
```
- **trainer** — DTensor/FSDP2 (Distributed Tensors + Fully Sharded Data Parallel
v2) policy workers run the forward/backward and the GRPO optimizer step. An
optional JAX (Flax NNX) backend implements the same
policy interface and is selected per config. Placement is `SPREAD` so shards
distribute across nodes.
- **inference** — a vLLM async engine (SGLang available) generates rollouts.
Placement is `PACK` to keep an engine's tensor-parallel ranks on one node.
Weights arrive from the trainer over a NCCL collective.
- **sandbox** — CPU-only `ubuntu-swe` containers run a batch task executor
(`POST /task/submit`, port 9090). It clones the repo, applies the agent patch
and the gold `test_patch`, runs the tests, and returns a verdict. The agent
never reaches the executor directly — the environment and reward layers drive
it through the `sandbox/` client.
Non-colocation is what makes the loop asynchronous: the trainer never blocks on
generation, and generation never blocks on scoring.
## The GRPO loop
The loop lives in `algorithms/grpo.py`. One optimizer step:
1. **Sample prompts.** `num_prompts_per_step` prompts are drawn; each becomes a
group of `num_generations_per_prompt` rollouts.
2. **Generate.** The inference fleet produces the rollouts. For SWE tasks each
rollout is single-shot (`max_rollout_turns=1`): the agent emits one patch.
3. **Score.** Each patch is submitted to a sandbox task executor. The verdict
(binary resolved/unresolved, or test pass-rate) is the raw reward.
4. **Shape rewards.** Optional reward shaping (overlong penalties), reward
scaling, and the native invalid-action / malformed-thinking penalty are
applied. The integrity check zeroes the reward if the patch edited a held-out
test file.
5. **Estimate advantages.** GRPO computes group-relative advantages with a
leave-one-out baseline and reward normalization — no value network.
6. **Recompute log-probs.** The policy computes current log-probs (and, unless
skipped, reference-policy log-probs) for the rollouts.
7. **Loss and step.** The clipped policy-gradient loss (`ClippedPGLossFn`) with
ratio clipping, optional dual-clip, a reference-KL (Kullback–Leibler) penalty,
and optional
importance-sampling correction produces gradients; the optimizer steps.
8. **Refit.** Updated weights are synchronized to the inference fleet (below),
and the loop repeats.
Validation runs every `grpo.val_period` steps (and optionally at start/end),
generating on a held-out set and reporting the metric that drives checkpoint
retention.
## Synchronous and asynchronous loops
`grpo_train()` is synchronous: generate the whole batch, score, train. The math
is on-policy and simplest to reason about.
`async_grpo_train()` decouples generation from optimization with a **replay
buffer** (`algorithms/async_utils/`). Rollouts are collected continuously by a
trajectory collector; an optimizer step consumes whatever is ready. Two
mechanisms keep this sound:
- **Trajectory age.** A trajectory stays eligible for
`async_grpo.max_trajectory_age_steps` optimizer steps. Older experience is
dropped, bounding how off-policy the data can be.
- **Importance-sampling correction.** Because consumed rollouts were generated
by a slightly older policy, async GRPO **requires**
`loss_fn.use_importance_sampling_correction=true`; the loss reweights by the
ratio of the current to the generating policy.
Async also allows `in_flight_weight_updates`: the trainer pushes new weights to
the vLLM engine while it is mid-generation, optionally recomputing the KV cache
afterward.
## The weight-staleness handshake
The trainer's weights advance every step; the inference fleet's weights only
change on refit. A `WeightSynchronizer` (`weight_sync/`) mediates this with a
small state machine the loop drives explicitly:
- After an optimizer step, the trainer calls `mark_stale()` — the inference
weights are now behind.
- Before generating, if `is_stale`, the loop calls `sync_weights()` to push the
trainer's weights, then `prepare_for_generation()` on the engine.
The synchronizer is built by `create_weight_synchronizer()`, which selects the
transport from the deployment topology: a NCCL collective for the GPU-to-GPU
non-colocated path, or IPC/ZMQ and HTTP transports for other layouts. The
trainer exposes its weights for the collective through a refit seam that maps
its internal parameter layout (DTensor shards, or JAX/NNX arrays) to the HF
names and shapes the inference engine expects.
## Weights vs. experience
Two distinct data flows cross the fleet boundary, with different transports:
- **Weights** (trainer → inference) move over the weight-sync transport (NCCL
collective in the canonical topology).
- **Experience** (rollouts, log-prob tensors) can move over an optional
**data plane** (`data_plane/`) instead of Ray's object store, so a large
`(B, S)` tensor never makes a driver round-trip. With `data_plane=None` the
in-memory colocated path is used.
## Where the code lives
| Area | Package |
| --- | --- |
| GRPO loop, loss, advantage estimators | `algorithms/` |
| Policy workers (DTensor / JAX) and generation (vLLM / SGLang) | `models/` |
| Ray bootstrap, fleets, placement, NCCL sync | `cluster/` |
| Virtual cluster, worker groups, batched data containers | `distributed/` |
| Datasets, processors, registries | `data/` |
| Single-shot SWE patch scoring | `environments/` |
| Reward computation and integrity verification | `rewards/` |
| Task-executor client | `sandbox/` |
| `ubuntu-swe` image and the task executor | `ubuntu-base/` |
| Weight-sync transports | `weight_sync/` |
| Experience data plane | `data_plane/` |
| Launchers and configs | `examples/` |
Each of these is covered in more depth in its design document.