# Policy versions and trajectory control

A long rollout can outlive several optimizer updates. River lets you choose
which weights generate its next tokens, how old those tokens may be when
trained, and whether a group should wait for unfinished rollouts. These are
separate decisions.

The starting recipe is trajectory-pinned sampling, a bounded training age with
`stale_policy="wait"`, and `GroupCompletion(mode="wait")`.

## One trajectory can contain several policies

A **policy version** identifies model weights after an optimizer update. A
**turn** is an assistant response, possibly ending in tool calls. A **segment**
is the portion generated by one sampling request. Reaching `segment_tokens`
continues the same turn in another request, subject to the rollout budget.

`sampling_policy="trajectory"` pins the first segment's policy for the whole
conversation. With `sampling_policy="segment"`, each request can select newer
weights. This includes continuations within a turn, so a single assistant
response can contain tokens generated by different policies. Every generated
span retains its actual policy version and sampling log probabilities.

<figure class="docs-diagram">
<svg viewBox="0 0 900 344" role="img" aria-labelledby="pinning-title pinning-desc">
<title id="pinning-title">Pinned versus per-segment sampling</title>
<desc id="pinning-desc">Both trajectories contain two sampling segments in the first assistant turn and a third segment after a tool result. The pinned trajectory uses policy 10 throughout. Per-segment sampling uses policies 10, 11, and 12 as the trainer advances. At training policy 12 their ages are respectively 2, 2, 2 and 2, 1, 0.</desc>
<line x1="450" y1="16" x2="450" y2="326" class="diagram-line"/>
<text x="24" y="36" class="diagram-heading">Pinned trajectory</text>
<text x="24" y="66" class="diagram-note">Trainer advances: 10 → 11 → 12</text>
<rect x="32" y="113" width="115" height="73" rx="6" class="diagram-sample"/>
<text x="89" y="139" text-anchor="middle">Segment 1</text>
<text x="89" y="168" text-anchor="middle">policy 10</text>
<rect x="165" y="113" width="115" height="73" rx="6" class="diagram-sample"/>
<text x="222" y="139" text-anchor="middle">Segment 2</text>
<text x="222" y="168" text-anchor="middle">policy 10</text>
<rect x="298" y="113" width="115" height="73" rx="6" class="diagram-sample"/>
<text x="355" y="139" text-anchor="middle">Segment 3</text>
<text x="355" y="168" text-anchor="middle">policy 10</text>
<line x1="32" y1="101" x2="280" y2="101" class="diagram-line"/>
<text x="154" y="94" text-anchor="middle">Turn 1</text>
<text x="356" y="94" text-anchor="middle">Turn 2</text>
<text x="289" y="211" class="diagram-note" text-anchor="middle">↑ tool result</text>
<text x="32" y="252" class="diagram-note">Policy ages at training version 12:</text>
<text x="32" y="283">2, 2, 2</text>
<text x="32" y="314" class="diagram-note">One policy for the entire conversation.</text>
<text x="478" y="36" class="diagram-heading">Per-segment policy</text>
<text x="478" y="66" class="diagram-note">Trainer advances: 10 → 11 → 12</text>
<rect x="486" y="113" width="115" height="73" rx="6" class="diagram-sample"/>
<text x="543" y="139" text-anchor="middle">Segment 1</text>
<text x="543" y="168" text-anchor="middle">policy 10</text>
<rect x="619" y="113" width="115" height="73" rx="6" class="diagram-sample"/>
<text x="676" y="139" text-anchor="middle">Segment 2</text>
<text x="676" y="168" text-anchor="middle">policy 11</text>
<rect x="752" y="113" width="115" height="73" rx="6" class="diagram-sample"/>
<text x="809" y="139" text-anchor="middle">Segment 3</text>
<text x="809" y="168" text-anchor="middle">policy 12</text>
<line x1="486" y1="101" x2="734" y2="101" class="diagram-line"/>
<text x="608" y="94" text-anchor="middle">Turn 1</text>
<text x="810" y="94" text-anchor="middle">Turn 2</text>
<text x="743" y="211" class="diagram-note" text-anchor="middle">↑ tool result</text>
<text x="486" y="252" class="diagram-note">Policy ages at training version 12:</text>
<text x="486" y="283">2, 1, 0</text>
<text x="486" y="314" class="diagram-note">One conversation, multiple policies.</text>
</svg>
<figcaption>Each segment is one sampling request. A request keeps its selected weights until it completes; newer weights can be selected for the next request.</figcaption>
</figure>

| RolloutEngine setting | Pinned trajectory | Per-segment policy |
| --- | --- | --- |
| `sampling_policy` | `"trajectory"` (default) | `"segment"` |
| Sampling weights | The policy selected for the first segment. | A policy selected for each sampling request, subject to the KV bound. |
| Retained prefix | Same-policy KV reuse. | Bounded-age KV reuse, or an explicitly allowed re-prefill. |
| Training age | Every segment has the same policy age. | Each segment has its own age; newer tokens do not make earlier tokens younger. |

The changing-policy diagram assumes compatible weights and retained-KV support
are available. Per-segment sampling does not guarantee the latest weights:
`KVCache(on_limit="hold")` can keep it on an older compatible policy.

## Wait for old trajectories or mask old spans

`AsyncTrainer.max_staleness` bounds the distance from **each generated span's
sampling policy** to the policy used for forward/backward. With a bound of 2,
policy-10 tokens may train against policies 10, 11, or 12.

<figure class="docs-diagram">
<svg viewBox="0 0 900 292" role="img" aria-labelledby="staleness-title staleness-desc">
<title id="staleness-title">Wait versus mask with a two-update staleness bound</title>
<desc id="staleness-desc">A trajectory has segments from policies 10, 11, and 12. Wait preserves its opportunity to train against policy 12, where all segments are within the age bound. If the trainer advances to policy 13 under mask_span, policy 10 tokens are masked out of the training loss while policy 11 and 12 tokens remain trainable.</desc>
<line x1="450" y1="16" x2="450" y2="277" class="diagram-line"/>
<text x="24" y="35" class="diagram-heading">Wait</text>
<text x="24" y="65" class="diagram-note">Training policy 12 · max_staleness=2</text>
<rect x="32" y="90" width="115" height="86" rx="6" class="diagram-train"/>
<text x="89" y="115" text-anchor="middle">policy 10</text>
<text x="89" y="139" text-anchor="middle">age 2</text>
<text x="89" y="162" text-anchor="middle">train</text>
<rect x="165" y="90" width="115" height="86" rx="6" class="diagram-train"/>
<text x="222" y="115" text-anchor="middle">policy 11</text>
<text x="222" y="139" text-anchor="middle">age 1</text>
<text x="222" y="162" text-anchor="middle">train</text>
<rect x="298" y="90" width="115" height="86" rx="6" class="diagram-train"/>
<text x="355" y="115" text-anchor="middle">policy 12</text>
<text x="355" y="139" text-anchor="middle">age 0</text>
<text x="355" y="162" text-anchor="middle">train</text>
<text x="32" y="211" class="diagram-note">All segments contribute.</text>
<text x="32" y="239" class="diagram-note">Hold updates before this group expires.</text>
<text x="32" y="267" class="diagram-note">Sampling and tools can keep working.</text>
<text x="478" y="35" class="diagram-heading">Mask old spans</text>
<text x="478" y="65" class="diagram-note">Training policy 13 · max_staleness=2</text>
<rect x="486" y="90" width="115" height="86" rx="6" class="diagram-masked"/>
<text x="543" y="115" text-anchor="middle">policy 10</text>
<text x="543" y="139" text-anchor="middle">age 3</text>
<text x="543" y="162" text-anchor="middle">masked</text>
<rect x="619" y="90" width="115" height="86" rx="6" class="diagram-train"/>
<text x="676" y="115" text-anchor="middle">policy 11</text>
<text x="676" y="139" text-anchor="middle">age 2</text>
<text x="676" y="162" text-anchor="middle">train</text>
<rect x="752" y="90" width="115" height="86" rx="6" class="diagram-train"/>
<text x="809" y="115" text-anchor="middle">policy 12</text>
<text x="809" y="139" text-anchor="middle">age 1</text>
<text x="809" y="162" text-anchor="middle">train</text>
<text x="486" y="211" class="diagram-note">Only in-bound spans contribute.</text>
<text x="486" y="239" class="diagram-note">Old tokens remain in the context.</text>
<text x="486" y="267" class="diagram-note">A pinned trajectory can be fully masked.</text>
</svg>
<figcaption>The sampling-policy choice determines which versions a trajectory contains. The stale-policy choice determines which generated tokens may contribute to training.</figcaption>
</figure>

| `stale_policy` | Behavior | Tradeoff |
| --- | --- | --- |
| `"wait"` (default) | Holds optimizer progress so outstanding whole groups can train within the bound. Sampling and tools continue. | Preserves the groups' opportunity to train; slow trajectories can delay updates. |
| `"mask_span"` | Masks generated spans that exceed the bound at training time. Their tokens remain in the conditioning context. | Updates need not wait for old spans, but some or all of a trajectory's training signal can be lost. Requires `allow_trajectory_loss=True`. |
| `"keep"` | Trains spans regardless of age. | Does not enforce `max_staleness`; requires `allow_unbounded_staleness=True`. |

The wait policy accounts for outstanding groups before they expire. It can
hold an update earlier than the illustrated limit when several groups still
need to fit into the remaining policy versions. A trajectory's own token and
turn limits still apply.

Masking changes which tokens contribute to the loss; it does not regenerate
them. With a pinned trajectory, every segment ages together, so the entire
trajectory can become masked. A batch with no usable gradient produces no
optimizer update.

## Finish a group or bound its stragglers

`GroupCompletion` answers a different question: when are enough rollouts from
one question ready to form a training group? `mode="wait"` waits for every
member. `mode="deadline"` starts a generated-token allowance once
`min_members` have completed.

For example, suppose two of four rollouts have finished:

| Completion choice | Two finished rollouts | Two unfinished rollouts |
| --- | --- | --- |
| `mode="wait"` | Wait for the whole group. | Continue within their rollout budgets. |
| `mode="deadline", on_stragglers="truncate"` | Join the group with the truncated members. | Stop at the deadline; the truncation policy decides their reward and training treatment. |
| `mode="deadline", on_stragglers="discard"` | Form a partial group. | Stop and contribute no training data. |
| `mode="deadline", on_stragglers="carry_over"` | Become available before the tails finish. | Continue for later training; requires `Batchwise` advantages. |

A deadline is reached when any unfinished member uses its
`max_straggler_tokens` allowance after that quorum. It is checked between
sampling segments and cannot interrupt an in-flight request. It is not a
wall-clock timeout.

**Discard is a group-completion option, not a stale-policy option.** Partial-group
completion (`discard` or `carry_over`) cannot use `stale_policy="wait"`; it
requires `"mask_span"` or `"keep"`. Deadline truncation and discard also require
`allow_trajectory_loss=True`. Carry-over requires asynchronous admission.

## Keep older KV or rebuild the prefix

Trajectory-pinned sampling can reuse the unchanged prefix under the same policy
in both synchronous and asynchronous training. With `sampling_policy="segment"`,
the engine can extend an older prefix using newer weights. Configure its KV
policy separately from the trainer's age limit:

```python
kv_cache = rl.KVCache(max_staleness=1, on_limit="hold")
```

Pass this as `kv_cache=kv_cache` to `RolloutEngine` with
`sampling_policy="segment"`. The trainer can still use `max_staleness=2`.

| Bound | Compared policies |
| --- | --- |
| Trainer `max_staleness=2` | Each generated segment versus the policy used to train it. |
| KV `max_staleness=1` | The retained cache's anchor policy versus the policy sampling the next segment. |

Consider a prefix anchored at policy 10 with a KV age limit of 1:

| `KVCache` choice | When policy 11 is available | When policy 12 is available |
| --- | --- | --- |
| `max_staleness=1, on_limit="hold"` | Can extend the retained cache with policy 11. | Keeps sampling at a compatible policy instead of advancing beyond the bound. |
| `max_staleness=1, on_limit="refill", allow_reprefill=True` | Can extend the retained cache with policy 11. | Can switch to policy 12 by rebuilding the prefix under those weights. |

If using current weights matters more than preserving the prefix, choose:

```python
kv_cache = rl.KVCache(
    max_staleness=1,
    on_limit="refill",
    allow_reprefill=True,
)
```

Crossing the KV age limit starts a new prefill under current weights. The explicit
flag acknowledges that cost. Availability depends on the model's capabilities;
the engine checks support before sampling.

`sampling_policy="segment"` requires either a positive KV age limit or explicit
re-prefill permission. A zero KV age limit with `on_limit="hold"` is rejected.
Trajectory pinning uses same-policy KV and does not need these cross-policy
controls. Cache reuse still depends on residency and model support; a worker
change or eviction can require a prefill in either mode.

### Check actual cache reuse

`sampling/cached_prompt_fraction` in `step.metrics` is the fraction of reported
prompt tokens served from cache. It is not a percentage of requests or a promise
that a retained prefix stayed resident. Cache eviction, worker changes, or a
new context can require a prefill. Image handles save repeated uploads but do
not themselves guarantee a KV-cache hit.
