> [`./BEHAVIOR.md`](./BEHAVIOR.md) (to be written once the N-stage path
> is deployed).
## 1. Goal
`pp-smoke-run --vastai --num-stages N --api-key <key>` runs a linear
chain of `N ≥ 2` GPU nodes that jointly serve one `InferenceRequest`
end-to-end. Acceptance test: `N = 5` on vast.ai, `llama3.2:1b`,
prompt `"Say hello"`, produces non-empty text within the 5-minute
budget. The localhost (`--seed`) path supports the same N values via
spawning N child processes.
`N = 1` is **not** supported by this example — single-node inference
is what `examples/single-gpu-inference` exists for, and the role-based
actor design here is built on the assumption that "first" and "last"
are distinct stages. `N = 2` is the minimum: first stage handles the
embed + first half; last stage handles the second half + head; no
middle stages.
## 2. What changes vs. the 2-stage MVP
| Area | 2-stage today | N-stage target |
|---|---|---|
| `pp-gpu-node` binary | hard-checks `NUM_STAGES == 2` (line 214) and dispatches to `run_stage_0` or `run_stage_1` | accepts any `N ≥ 2`; dispatches to a single `run_stage` function parameterised by `(stage, num_stages)` |
| Stage actors | `Stage0Actor` and `Stage1Actor` are separate types | one `StageActor` parameterised by **role** (`First` / `Middle` / `Last`) |
| Message bridges | three bridge types (`Stage0RequestBridge`, `Stage0NextTokenBridge`, `Stage1ActivationBridge`) bound to specific actor types | same three bridge shapes, but generic over the unified `StageMsg` |
| Orchestrator (`pp-smoke-run`) | spawns exactly two children, hands stage 1 stage 0's `PEER_DIRECT` | spawns `N` children in order; each non-first child receives its **predecessor's** addressing as `PEER_DIRECT` |
| **Middle** | drop | `forward_range` → send `StageActivation { … }` to `next`, preserving `request_id`, `position`, `seq_len`, `is_prefill` | drop |
| **Last** | drop | `forward_and_sample` → send `NextToken` to `prev` (= the first stage); on terminate, `detokenize` → send `InferenceResponse` to `reply_to` | drop |
| Fewer than N offers available | `find_offer` returns empty on attempt <N|Destroyanyallocatedinstances,exit1.|
| Any one of N instances never reaches running | Per-contract `wait_for_running` | Destroy *all* contracts, exit 1. |
| SWIM never converges to N alive peers | 180s timeout in vast.ai mode | Destroy all, exit 1. Fetch logs from each instance. |
| Any one of N workers never reports ready | Per-node `wait_for_worker_ready` (600s) | That stage exits 1; SWIM marks it dead; orchestrator times out on convergence and destroys all. |
| Middle stage drops a `StageActivation` (mid-decode) | Orchestrator's 600s response timeout | Destroy all, exit 1. Recovery is rung 6 (fault tolerance), out of scope here. |
The teardown invariant — "every code path that creates an instance
also destroys it" — generalises to "any creation failure rolls back
all already-created instances." `destroy_all_instances` already does
this; the loops above just have to call it on every error branch.
## 7. Test plan
The full enumeration of behavioural tests lives in
[`./TEST_SPEC.md`](./TEST_SPEC.md). Summary of tiers in landing order:
1.**Pure-Rust unit** — codec, topology helpers, role computation,
per-stage layer-range math.
2.**Python worker contract** — `forward_range` (new), all existing
ops verified against stub mode, layer-range math up to N=8.
3.**Stage actor (in-process, stub worker)** — `StageActor` in each
of its three roles plus role-mismatch defensive drops.
4.**Cluster transport (in-process, real iroh)** — N-node clusters
(N ∈ {2, 3, 4}) carry every message type intact across every hop.
5.**vast.ai client (mocked HTTP)** — N-instance create/destroy with
distinct `STAGE` env vars and rollback on partial failure.
6.**In-process N-stage integration (stub worker, real iroh)** —
full chain at N ∈ {2, 3, 4, 5}.
7.**Sliced-vs-full equivalence (real tinygrad on CPU, gated)** —
the load-bearing correctness test: N-stage output token sequence
matches single-process `Transformer.generate()` up to `max_tokens`.
Run at N=2 (carried forward), N=3, and N=4.
8.**Localised binary E2E (`pp-smoke-run --seed`)** — the
**pre-deploy gate**: runs the actual `pp-gpu-node` binaries
spawned by the actual `pp-smoke-run` binary in `--seed` mode at
N ∈ {2, 3, 5}.
9.**Manual smoke run on vast.ai at N=5** — gated, rare, the
acceptance test for the rung.
A change that breaks any tier 1–4 test must not pass review. Tier 8
must pass before any change is deployed. Tiers 5–7 are CI-gated;
real-tinygrad and equivalence are `#[ignore]` and run on demand or in
a slow lane.
## 8. Migration
Land in this order, each its own PR:
1.**Worker op `forward_range`.** Additive in
`pp_tinygrad_worker.py`. No call sites yet. Tested with Python
tests in `test_worker.py`.
2.**Unify `Stage0Actor` + `Stage1Actor` into `StageActor`.** Keep
message-shape behaviour identical for N=2. Delete the old types.
Existing `t_actor.rs` tests adapt to the unified API. **This PR
does not change the wire or the binary.**
3.**Add `Middle` role + actor logic** behind a constructor variant.
Add N=3 / N=4 integration tests against the stub worker. Binary
still rejects N != 2 to avoid premature deploys.
4.**Generalise `pp-gpu-node`** (drop the N==2 check; generalise the
resolve/route step). Update `t_binary.rs` to cover N=3 in seed