swactor/examples/pipeline-parallel-inference/TEST_SPEC.md
Zachery Aaron Shores-Chmielewski b23f82e9a9 feat(distribution): add diagnostics subsystem
Structured observability for the iroh/SWIM layer: Aggregator, typed Event/Snapshot
types, Sink (NoopSink default), ProbeScheduler, process stats, and host/iroh/swim
introspection, plus the swactor-diag-collector, -postproc, and -iroh-relay binaries
that assemble and render per-run bundles. Generalizes the pipeline-parallel-inference
example to N stages and adds the topology-planner spec.


Signed-off-by: Zachery Aaron Shores-Chmielewski <zacheryasc@gmail.com>
2026-05-20 11:41:30 +04:00

525 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Test Spec: Pipeline-Parallel Inference, N Stages
> Companion to [`./SPEC.md`](./SPEC.md). This document enumerates
> every component surface in the N-stage spec and names the
> behavioural test that proves it correct. No implementations here —
> just the test name and a one-line statement of what passing the
> test proves.
## Test Philosophy
* **Fail fast, cheap first.** Pure-Rust unit tests run on every save.
Local multi-process integration runs on every commit. Anything
touching real vast.ai is gated and rare.
* **Behaviour over structure.** Tests describe observable outcomes
(a response arrives, a token id is valid, a cluster converges).
They do not assert internal call sequences or echo the
implementation.
* **One killer correctness test.** "Non-empty response" is too low a
bar for sliced inference — silently wrong outputs would pass.
Equivalence against single-node inference (§12) is the load-bearing
test.
* **Tier 9 is the pre-deploy gate.** The localised binary E2E at
`N = 5` must pass before any vast.ai deploy. It is the only test
that exercises the actual binaries against each other in their
intended N-stage configuration; everything below it is either
unit-shaped or replaces real bits with stubs.
* **Coverage scope.** Every component that runs as part of an N-stage
deployment has at least one tier-1–4 test that runs without a
cluster, plus a tier-5+ test that runs it in an integrated setting.
We do not ship a component whose only proving ground is the
deployed system.
## Test Tiers
| Tier | What runs | When | Wall-clock |
|---|---|---|---|
| 1. Pure unit (Rust) | codec, topology helpers, role computation, layer-range math | `cargo test` | < 1 s |
| 2. Python worker contract | stdin/stdout protocol, stub mode, all three op sets | `pytest` | seconds |
| 3. Stage actor (Rust, in-process) | one `StageActor` per role + its worker subprocess | `cargo test` | seconds |
| 4. Bridges (Rust, in-process) | `RequestBridge`, `NextTokenBridge`, `ActivationBridge` adapter behaviour | `cargo test` | < 1 s |
| 5. Cluster transport (Rust, in-process, real iroh) | N-node swactor cluster carrying every message type | `cargo test` | seconds |
| 6. Orchestrator setup (Rust, in-process) | spawn-chain address propagation, find-offer loop, rollback | `cargo test` | seconds |
| 7. vast.ai client (Rust, mocked HTTP) | N-instance create/destroy with mocked vast.ai | `cargo test` | < 1 s |
| 8. In-process integration (Rust, stub worker, real iroh) | full N-stage pipeline in one process for N ∈ {2,3,4,5} | `cargo test` | seconds |
| 9. **Localised binary E2E (`pp-smoke-run --seed`)** | actual binaries against each other at N ∈ {2,3,5} with stub workers | `cargo test -- --ignored` (gate) | minutes |
| 10. Sliced-vs-full equivalence (real tinygrad on CPU) | per-stage output matches single-node `Transformer.generate()` | `cargo test -- --ignored` | minutes |
| 11. Real-tinygrad binary E2E (localhost) | binaries with real GGUF worker on CPU | `cargo test -- --ignored` | minutes |
| 12. vast.ai full run | actual smoke run on rented hardware at N=5 | manual only | ~10 min |
---
## 1. Message Codec — `tests/t_codec.rs`
Surface: `InferenceRequest`, `InferenceResponse`, `StageActivation`,
`NextToken` defined in `messages.rs`. No semantic change from the
2-stage codec — the same codec carries activations across every
intermediate hop. Tests are listed in full rather than incrementally
because some 2-stage tests now imply more (every roundtrip is
exercised across `(N - 1)` hops at runtime).
| Test name | Proves |
|---|---|
| `stage_activation_roundtrips_through_codec_and_registry` | `StageActivation` with non-trivial binary `hidden` payload encodes and decodes to identity. |
| `stage_activation_roundtrips_with_varied_seq_len` | `StageActivation` with `seq_len ∈ {1, 2, 32, 256}` roundtrips intact — prefill and decode steps must both survive the wire. |
| `next_token_roundtrips_through_codec_and_registry` | `NextToken` encodes and decodes to identity including `done` flag. |
| `inference_request_roundtrips_with_max_tokens_field` | `InferenceRequest` including `reply_to` and `max_tokens` roundtrips. |
| `inference_response_roundtrips_through_codec_and_registry` | `InferenceResponse` with arbitrary UTF-8 text roundtrips. |
| `corrupted_bytes_produce_error_for_stage_activation` | Random byte flips in encoded `StageActivation` decode to `Err`, never panic. |
| `truncated_stage_activation_produces_error` | Truncating the `hidden` blob is rejected with `Err`. |
| `wrong_message_type_produces_error` | Decoding bytes of one type with the codec of another returns `Err`. |
| `empty_bytes_produce_error` | Empty input decodes to `Err` for every message type. |
| `codec_registry_dispatches_all_four_types` | All four `NetworkMessage` types are registered and decode correctly when dispatched by tag. |
---
## 2. Layer-Range and Sampling Math — `tests/test_worker.py::TestLayerMath`
Surface: the layer-slice computation and argmax sampling described in
SPEC §3.4 and §4. Generalised for arbitrary N.
| Test name | Proves |
|---|---|
| `test_stage_0_layer_range_starts_at_zero` | `STAGE=0` always yields `(0, …)` for any `NUM_STAGES`. |
| `test_last_stage_layer_range_reaches_total` | `STAGE=N-1` always yields `(…, total)` for any `NUM_STAGES`, including when `total` is not divisible by `N`. |
| `test_layer_range_partition_is_total_coverage` | The union of all stage ranges equals `[0, total)` with no gaps or overlap, for `NUM_STAGES ∈ {2, 3, 4, 5, 6, 7, 8}`. Property test. |
| `test_layer_range_each_stage_owns_at_least_one_block` | For any `NUM_STAGES ≤ total`, no stage's range is empty. |
| `test_layer_range_partition_for_indivisible_totals` | `total = 17, N = 4` produces 4 contiguous ranges that cover `[0, 17)` and differ by at most one block in length. |
| `test_layer_range_rejects_num_stages_one` | `NUM_STAGES = 1` is rejected by the worker at boot (the example does not serve `N = 1`). |
| `test_layer_range_rejects_num_stages_zero_or_oversize` | `NUM_STAGES = 0` and `STAGE >= NUM_STAGES` exit non-zero. |
| `test_argmax_sampling_is_deterministic` | Same logits tensor → same `token_id`, repeated calls. |
---
## 3. Role Computation — `tests/t_role.rs` (new)
Surface: the pure function `StageRole::for_stage(stage: u32, num_stages: u32) -> StageRole`
defined alongside `StageRole` in `stage_actor.rs`. Pure function, no
runtime needed.
| Test name | Proves |
|---|---|
| `stage_zero_is_first_for_any_num_stages` | `(0, N) → First` for `N ∈ {2..8}`. |
| `last_index_is_last_for_any_num_stages` | `(N-1, N) → Last` for `N ∈ {2..8}`. |
| `middle_indices_are_middle` | `(i, N) → Middle` for every `0 < i < N - 1`, `N ∈ {3..8}`. |
| `n_stages_two_has_no_middle` | At `N = 2`, only `First` and `Last` are produced; no input yields `Middle`. |
| `role_partition_property` | For every `N ∈ {2..16}`, exactly one stage is `First`, exactly one is `Last`, and the rest are `Middle`. |
---
## 4. Topology Helpers — `tests/t_topology.rs`
Surface: `topology.rs`. Helpers already parameterise on N; tests are
extended to assert that generalisation holds at N > 2 and that name
registration matches the role.
| Test name | Proves |
|---|---|
| `stage_name_uses_pp_prefix` | `stage_name(i) == "pp-stage-{i}"` for `i ∈ {0..16}`. |
| `first_stage_has_no_prev` | `prev_stage_name(0) == None`. |
| `last_stage_has_no_next` | `next_stage_name(N-1, N) == None` for `N ∈ {2..8}`. |
| `middle_stage_has_both_neighbours` | For `0 < i < N - 1`, `prev_stage_name(i)` and `next_stage_name(i, N)` are both `Some(_)` and differ. |
| `first_stage_registers_entry_and_index` | `register_stage_names(_, 0, N, addr)` registers `pp-entry` and `pp-stage-0`, both resolving to `addr`. |
| `last_stage_registers_exit_and_index` | `register_stage_names(_, N-1, N, addr)` registers `pp-exit` and `pp-stage-{N-1}`. |
| `middle_stage_registers_index_only` | `register_stage_names(_, i, N, addr)` with `0 < i < N - 1` registers only `pp-stage-{i}`. |
| `register_stage_names_count_matches_role` | Returned `Vec<String>` length is 2 for First and Last, 1 for Middle. |
| `register_stage_names_uniqueness` | Across all stages in `{0..N}`, the per-index names are pairwise distinct; `pp-entry` and `pp-exit` are each registered exactly once. |
| `next_stage_name_computed_from_env_alone` | A stage given `STAGE=i`, `NUM_STAGES=N` computes its outbound target name without any external config or network access. |
---
## 5. Python Worker Contract — `tests/test_worker.py`
Surface: `pp_tinygrad_worker.py` stdin/stdout protocol described in
SPEC §3.4 and the original 2-stage SPEC §4. Run against `--stub` mode
that skips real GGUF load.
### 5.1 TestWorkerStartup
| Test name | Proves |
|---|---|
| `test_worker_emits_ready_with_pid_and_stage` | Startup writes `{"status": "ready", "pid": <int>, "stage": <int>}` to stdout. |
| `test_worker_rejects_invalid_stage_env` | `STAGE` outside `[0, NUM_STAGES)` causes immediate exit with non-zero code. |
| `test_worker_rejects_num_stages_one` | `NUM_STAGES=1` exits non-zero (single-node configurations are not served by this example). |
### 5.2 TestFirstStageOperations (stub mode, `STAGE=0`, `NUM_STAGES=4`)
| Test name | Proves |
|---|---|
| `test_tokenize_returns_token_ids_for_prompt` | `op=tokenize` returns `{"request_id": rid, "tokens": [...]}` with at least one id for a non-empty prompt. |
| `test_embed_and_forward_returns_hidden_for_prompt` | `op=embed_and_forward` with a tokenized prompt returns a base64 `hidden` whose decoded byte length matches `seq_len * hidden_dim * 2` (bf16). |
| `test_decode_step_returns_hidden_for_single_token` | `op=decode_step` returns hidden bytes of length `1 * hidden_dim * 2`. |
| `test_kv_cache_grows_across_successive_decode_steps` | Successive `decode_step` calls at increasing `position` succeed; the worker does not error or reset state between them. |
| `test_first_stage_rejects_forward_range` | `op=forward_range` on `STAGE=0` returns `{"error": ...}`, worker stays alive. |
| `test_first_stage_rejects_forward_and_sample` | `op=forward_and_sample` on `STAGE=0` returns `{"error": ...}`, worker stays alive. |
### 5.3 TestMiddleStageOperations (stub mode, `STAGE=1`, `NUM_STAGES=4`) — NEW
| Test name | Proves |
|---|---|
| `test_forward_range_returns_hidden_with_input_seq_len` | `op=forward_range` with `hidden_b64`, `position=p`, `seq_len=s` returns hidden bytes of length `s * hidden_dim * 2`, `request_id` echoed. |
| `test_forward_range_is_deterministic_for_same_input` | Same `hidden_b64` + `position` + `seq_len` → same output hidden bytes. |
| `test_forward_range_kv_cache_advances_with_position` | Repeated `forward_range` at positions `0, prompt_len, prompt_len+1, …` succeed without resetting state. |
| `test_middle_stage_rejects_embed_and_forward` | `op=embed_and_forward` on a middle stage returns `{"error": ...}`. |
| `test_middle_stage_rejects_decode_step` | `op=decode_step` on a middle stage returns `{"error": ...}`. |
| `test_middle_stage_rejects_forward_and_sample` | `op=forward_and_sample` on a middle stage returns `{"error": ...}`. |
| `test_middle_stage_rejects_tokenize` | `op=tokenize` on a middle stage returns `{"error": ...}`. |
| `test_middle_stage_rejects_detokenize` | `op=detokenize` on a middle stage returns `{"error": ...}`. |
| `test_forward_range_rejects_seq_len_mismatch` | `hidden_b64` whose decoded length disagrees with declared `seq_len` is rejected with `{"error": ...}`. |
### 5.4 TestLastStageOperations (stub mode, `STAGE=N-1`)
| Test name | Proves |
|---|---|
| `test_forward_and_sample_returns_valid_token_id` | `op=forward_and_sample` returns a `token_id` in `[0, vocab_size)`. |
| `test_forward_and_sample_is_deterministic_for_same_input` | Same `hidden` + `position` → same `token_id`. |
| `test_detokenize_returns_text_for_token_ids` | `op=detokenize` returns `{"request_id": rid, "text": "..."}` for a valid id sequence. |
| `test_last_stage_rejects_embed_and_forward` | `op=embed_and_forward` on the last stage returns `{"error": ...}`. |
| `test_last_stage_rejects_forward_range` | `op=forward_range` on the last stage returns `{"error": ...}`. |
### 5.5 TestWorkerMalformedInput
| Test name | Proves |
|---|---|
| `test_malformed_json_returns_error_and_continues` | One garbage line yields an `{"error": ...}` reply and the worker accepts a valid request immediately after. |
| `test_missing_op_field_returns_error` | Valid JSON without `op` returns `{"error": ...}`. |
| `test_unknown_op_returns_error` | An `op` not in the worker's dispatch table returns `{"error": ...}`. |
| `test_missing_request_id_returns_error` | Valid op without `request_id` returns `{"error": ...}`. |
### 5.6 TestWorkerEOFShutdown
| Test name | Proves |
|---|---|
| `test_eof_causes_clean_exit` | Closing stdin makes the worker exit 0 within a few seconds. |
| `test_sigterm_causes_clean_exit_after_in_flight_op` | A `SIGTERM` while an op is in flight is honoured after the op completes; worker exits 0. |
---
## 6. Stage Actor — `tests/t_actor.rs`
Surface: the unified `StageActor` and its three roles. Uses the stub
worker. Tests are organised per role.
### 6.1 Common (all roles)
| Test name | Proves |
|---|---|
| `stage_actor_spawns_worker_and_reports_ready` | After spawn, the actor emits `WorkerReady { pid }` once the stub worker prints its ready line, for every role. |
| `stage_actor_worker_crash_emits_process_exited` | If the worker process dies, the actor reports `ProcessExited { status }` and the runtime is not poisoned, for every role. |
| `stopping_stage_actor_kills_child_worker` | Dropping/stopping the actor cleans up the worker pid, for every role. |
| `set_neighbors_updates_routing_addresses` | After `SetNeighbors`, subsequent outbound messages use the new addresses; the previous placeholder addresses are not used. |
| `reset_clears_per_request_state` | After `Reset`, the actor accepts a fresh request and does not double-handle anything from the prior request. |
### 6.2 StageRole::First
| Test name | Proves |
|---|---|
| `first_role_inference_request_produces_stage_activation_to_next` | Sending `InferenceRequest` causes the actor to emit a `StageActivation` to its configured `next` address, with `is_prefill=true` and `seq_len == prompt_token_count`. |
| `first_role_next_token_produces_stage_activation_for_decode_step` | Sending `NextToken { done: false }` causes the actor to emit `StageActivation` with `is_prefill=false`, `seq_len=1`, `position = nt.position`. |
| `first_role_next_token_with_done_produces_no_further_activations` | After `NextToken { done: true }`, the actor emits nothing more for that request. |
| `first_role_drops_activation_messages_defensively` | Sending `StageMsg::Activation` to a `First` actor produces no outbound `StageActivation` and does not crash. |
| `first_role_request_before_ready_is_dropped_without_panic` | An `InferenceRequest` arriving before `WorkerReady` is dropped silently; subsequent requests post-ready still succeed. |
### 6.3 StageRole::Middle — NEW
| Test name | Proves |
|---|---|
| `middle_role_activation_produces_activation_to_next` | Receiving `StageActivation` causes the actor to emit a new `StageActivation` to `next`, with the same `request_id`, `position`, `seq_len`, `is_prefill`. |
| `middle_role_preserves_control_fields_property` | Property test over random `(request_id, position, seq_len, is_prefill)`: a middle stage is a pass-through for those fields; only `hidden` may change. |
| `middle_role_drops_inference_requests_defensively` | Sending `InferenceRequest` to a `Middle` actor produces no outbound messages. |
| `middle_role_drops_next_tokens_defensively` | Sending `NextToken` to a `Middle` actor produces no outbound messages. |
| `middle_role_does_not_emit_inference_response` | A middle actor never emits `InferenceResponse`, regardless of input. |
### 6.4 StageRole::Last
| Test name | Proves |
|---|---|
| `last_role_activation_produces_next_token_to_first` | Receiving `StageActivation` causes the actor to emit `NextToken` to its configured `prev` address (which is stage 0 — the orchestrator wires it that way). |
| `last_role_accumulates_tokens_and_emits_response_on_eos` | After enough `StageActivation`s to produce the configured EOS token (stub-controlled), the actor emits `InferenceResponse { text }` with detokenised output to `reply_to`. |
| `last_role_emits_response_when_max_tokens_reached` | After `max_tokens` activations, the actor emits `InferenceResponse` even without EOS. |
| `last_role_drops_inference_requests_defensively` | Sending `InferenceRequest` to a `Last` actor produces no outbound messages. |
| `last_role_drops_next_tokens_defensively` | Sending `NextToken` to a `Last` actor produces no outbound messages. |
| `last_role_terminate_clears_pending_state` | After terminate, sending another `StageActivation` for the same `request_id` does not duplicate the `InferenceResponse`. |
---
## 7. Bridges — `tests/t_actor.rs::bridges` (or `tests/t_bridges.rs`)
Surface: `RequestBridge`, `NextTokenBridge`, `ActivationBridge` — the
thin actors that adapt a single network type into `StageMsg::*`.
Small, but the wiring contract is load-bearing.
| Test name | Proves |
|---|---|
| `request_bridge_forwards_inference_request_to_target` | A `RequestBridge` configured with `target = T` rewraps an incoming `InferenceRequest` as `StageMsg::Inference(req)` and sends it to `T`. |
| `next_token_bridge_forwards_next_token_to_target` | Same shape for `NextToken` → `StageMsg::NextToken(nt)`. |
| `activation_bridge_forwards_stage_activation_to_target` | Same shape for `StageActivation` → `StageMsg::Activation(act)`. |
| `bridges_drop_messages_when_target_address_invalid` | Sending to a bridge whose target address has been deregistered does not panic; the message is dropped silently. |
---
## 8. Cluster Transport — `tests/t_cluster.rs`
Surface: an N-node swactor cluster (1 orchestrator + N stages) inside
one test process, separate `DistributedNode`s connected via real
iroh in `RelayMode::Disabled`. Tests run for N ∈ {2, 3, 4}.
| Test name | Proves |
|---|---|
| `n_node_cluster_converges_via_iroh_seed_join[N]` | For `N ∈ {2, 3, 4}`, all nodes report each other alive within the SWIM convergence window. |
| `stage_activation_roundtrips_between_each_adjacent_pair[N]` | For each hop `(i → i+1)` in an N-stage cluster, a `StageActivation` arrives intact (bytes + scalars). |
| `next_token_roundtrips_last_to_first[N]` | A `NextToken` sent from stage `N-1` to stage 0 arrives intact (skipping any middle stages on the wire). |
| `inference_response_roundtrips_last_to_orchestrator[N]` | An `InferenceResponse` sent stage-`N-1` → orchestrator arrives intact. |
| `node_death_detected_via_swim_after_middle_stage_shutdown` | If a middle stage's node shuts down, the remaining nodes mark it dead within the SWIM timeout. |
| `node_death_detected_via_swim_after_first_or_last_stage_shutdown` | First and last stage deaths are detected the same way (no role bias in SWIM). |
The `[N]` suffix indicates a parameterised test; concrete cases are
generated by macro or `rstest`.
---
## 9. Orchestrator Setup Dance — `tests/t_orchestrator.rs` (new)
Surface: the spawn-chain address propagation, the find-offer loop,
and the rollback discipline described in SPEC §4. These are pulled
out of the binary into testable helpers
(`pp_smoke_run::seed::spawn_chain` and `pp_smoke_run::vastai::lease_chain`)
so they can be exercised without invoking the binary.
### 9.1 Seed spawn chain
| Test name | Proves |
|---|---|
| `spawn_chain_propagates_each_stage_peer_direct_to_successor` | For N child specs, child `i` is spawned with `PEER_DIRECT` set to child `i-1`'s announced direct addresses; child 0 has no `PEER_DIRECT`. |
| `spawn_chain_reads_pp_gpu_node_addr_in_order` | The chain waits for each child's `PP_GPU_NODE_ADDR` stdout line before spawning the next; children produced out of order are an error. |
| `spawn_chain_kills_already_spawned_on_failure` | If spawn of child `k` fails, children `0..k` are killed before the helper returns. |
| `spawn_chain_kills_already_spawned_on_addr_timeout` | If child `k` fails to announce its addr within the timeout, children `0..k` are killed. |
| `spawn_chain_supports_num_stages_two_through_eight` | The chain runs successfully for `N ∈ {2..8}` against a fake "child" that prints a fixed address line. |
### 9.2 vast.ai lease chain (mocked HTTP — see §10 for the client itself)
| Test name | Proves |
|---|---|
| `lease_chain_finds_n_distinct_offers` | `find_offer` is called N times; each call excludes all previously selected offer ids. |
| `lease_chain_creates_n_instances_with_distinct_stage_env` | `create_instance` is called N times; the `STAGE` env var on call `i` is `i`; `NUM_STAGES` is N on every call. |
| `lease_chain_rolls_back_on_partial_creation` | If `create_instance` call `k` fails, all `0..k` previously created contracts are destroyed before returning the error. |
| `lease_chain_waits_for_running_per_contract` | Each contract reaches `running` state (or times out) before the chain returns success. |
### 9.3 Convergence wait
| Test name | Proves |
|---|---|
| `await_convergence_returns_when_n_minus_one_peers_alive` | The orchestrator's convergence wait returns once `(N-1)` alive peers are seen (the orchestrator itself plus N stages = N+1 nodes total). |
| `await_convergence_returns_error_after_timeout` | If only `(N-2)` peers go alive before the timeout, the wait returns an error and does not consume more time. |
---
## 10. vast.ai Client Extensions — `tests/t_vastai.rs`
Surface: the vast.ai client functions that the lease chain (§9.2)
calls. Mocks HTTP via `wiremock`. Does not hit the real API.
| Test name | Proves |
|---|---|
| `create_n_instances_sends_distinct_stage_env_vars[N]` | For `N ∈ {2, 3, 5}`, N consecutive `create_instance` calls send `STAGE=0..N-1` in the env payload respectively. |
| `create_n_instances_all_receive_same_seed_addr[N]` | For `N ∈ {2, 3, 5}`, every creation call carries the same `SEED_ADDR`. |
| `create_n_instances_all_receive_same_num_stages_env[N]` | Every creation call carries `NUM_STAGES=N`. |
| `failure_to_create_kth_instance_triggers_destroy_of_prior` | If creation of instance `k` errors, the orchestrator destroys instances `0..k` before exiting. |
| `find_offer_excludes_all_prior_offer_ids` | The Nth `find_offer` call excludes the offer ids returned by the previous `N - 1` calls. |
| `find_offer_returns_error_when_fewer_than_n_offers_available` | If the API returns fewer than N distinct offers in total, the chain surfaces an error. |
| `destroy_all_instances_issues_n_delete_requests` | Cleanup issues exactly N DELETEs, one per instance id. |
| `destroy_all_continues_when_one_delete_fails` | A failure to destroy one instance does not prevent the attempt to destroy the others; the result vector contains both outcomes. |
---
## 11. In-Process N-Stage Integration — `tests/t_integration.rs`
Surface: orchestrator + N stages, each on a separate
`DistributedNode` inside one test process, real iroh
(`RelayMode::Disabled`), stub worker. Drives the autoregressive loop
end-to-end without real model compute.
| Test name | Proves |
|---|---|
| `n_stage_stub_pipeline_returns_response[N]` | For `N ∈ {2, 3, 4, 5}`, the in-process equivalent of `pp-smoke-run` sends `InferenceRequest { prompt: "Say hello", max_tokens: 4 }` and receives non-empty `InferenceResponse` from the last stage. |
| `decode_loop_terminates_on_stub_eos[N]` | For `N ∈ {2, 3, 4}`, a stub last-stage configured to emit EOS at token `K` causes the response to arrive with exactly `K` tokens. |
| `decode_loop_terminates_on_max_tokens[N]` | For `N ∈ {2, 3, 4}`, a stub last-stage that never emits EOS with `max_tokens=4` produces a response with 4 accumulated tokens. |
| `middle_stage_failure_mid_decode_surfaces_as_error_to_orchestrator` | At `N=4`, killing the worker of a middle stage mid-loop causes the orchestrator's request future to resolve to an error within the SWIM detection window, not hang. |
| `first_stage_failure_mid_decode_surfaces_as_error_to_orchestrator` | At `N=4`, killing the worker of the first stage mid-loop is detected as above. |
| `last_stage_failure_mid_decode_surfaces_as_error_to_orchestrator` | At `N=4`, killing the worker of the last stage mid-loop is detected as above. |
| `activation_request_id_round_trip_through_chain` | At `N=4`, the `request_id` on the `NextToken` returning from last → first matches the `request_id` of the `StageActivation` first → middle → middle → last. |
| `activation_position_advances_one_per_decode_step` | At `N=3`, with `max_tokens=4`, each `StageActivation` after prefill has `position` exactly one greater than the previous, end-to-end through the chain. |
---
## 12. Sliced-vs-Full Equivalence — `tests/t_integration.rs` (gated)
**The load-bearing correctness test.** Without this, every other test
could pass with silently wrong inference.
Surface: that the N-stage sliced forward pass produces the same
output as a single-process full forward pass on the same prompt and
model. Each test runs on CPU with real `llama3.2:1b` GGUF; minutes
per case; `#[ignore]`.
| Test name | Proves |
|---|---|
| `sliced_two_stage_inference_matches_single_node_for_say_hello` | At `N=2`, the token sequence produced by the pipeline equals tinygrad's single-node `Transformer.generate()` up to `max_tokens=8` on prompt `"Say hello"`. |
| `sliced_two_stage_inference_matches_single_node_for_three_diverse_prompts` | Same equivalence holds for three prompts (one word, one sentence, one with punctuation) at `N=2`. Catches off-by-one errors in position handling, KV cache misalignment, and layer-boundary tensor shape mismatches. |
| `sliced_three_stage_inference_matches_single_node_for_say_hello` | At `N=3`, equivalence on `"Say hello"`. Adds a middle stage to the load-bearing test. |
| `sliced_three_stage_inference_matches_single_node_for_three_diverse_prompts` | At `N=3`, equivalence on the three diverse prompts. |
| `sliced_four_stage_inference_matches_single_node_for_say_hello` | At `N=4`, equivalence on `"Say hello"`. Two middle stages — proves the middle-stage path composes. |
| `sliced_n_stage_inference_is_invariant_to_n_for_argmax` | The token sequence produced is **independent of N** for `N ∈ {2, 3, 4}` on the same prompt with argmax sampling. This is the test that catches any silent state corruption introduced by the chain length. |
These tests gate any merge that touches the worker or the actor's
worker-IPC. A passing tier 8 + a failing tier 12 means the chain
**delivers a response but it is wrong**: this is the failure mode the
test exists to prevent.
---
## 13. Localised Binary E2E — `tests/t_binary.rs`
**The pre-deploy gate.** This is the test the user explicitly called
out as gating any vast.ai deploy. It exercises the real
`pp-smoke-run --seed` binary spawning real `pp-gpu-node` child
processes against each other, with stub workers (so it does not need
a GPU). It is the highest-fidelity test that can run without
provisioning hardware.
Surface: `pp-smoke-run --seed --num-stages N` invoking the real
`pp-gpu-node` binary, `N` times, with stub workers, on localhost.
### 13.1 Happy paths
| Test name | Proves |
|---|---|
| `binary_e2e_n_stub_workers_returns_response[N]` | For `N ∈ {2, 3, 5}`, running `pp-smoke-run --seed --num-stages N` prints a non-empty response and exits 0 within 90s. |
| `binary_e2e_response_contains_accumulated_token_count` | At `N=5`, the response text reflects exactly `max_tokens` accumulated tokens (stub-worker contract). |
| `binary_e2e_orchestrator_registers_pp_orchestrator_name` | At `N=3`, the orchestrator's `pp-orchestrator` name resolves from any stage's perspective post-convergence. |
| `binary_e2e_all_stages_register_pp_stage_index_names` | At `N=4`, every `pp-stage-{i}` for `i ∈ {0..N}` resolves post-convergence. |
### 13.2 Failure / cleanup paths
| Test name | Proves |
|---|---|
| `binary_e2e_first_stage_killed_orchestrator_exits_nonzero[N]` | For `N ∈ {2, 3, 5}`, killing the first `pp-gpu-node` mid-run causes `pp-smoke-run` to exit non-zero. |
| `binary_e2e_middle_stage_killed_orchestrator_exits_nonzero` | At `N=5`, killing a middle `pp-gpu-node` mid-run causes `pp-smoke-run` to exit non-zero. |
| `binary_e2e_last_stage_killed_orchestrator_exits_nonzero[N]` | For `N ∈ {2, 3, 5}`, killing the last `pp-gpu-node` mid-run causes `pp-smoke-run` to exit non-zero. |
| `binary_e2e_no_orphaned_processes_after_clean_exit[N]` | After a successful run, no `pp-gpu-node` child processes remain alive (verified via process listing). |
| `binary_e2e_no_orphaned_processes_after_failed_exit[N]` | After a failed run (any of the kill scenarios above), no `pp-gpu-node` children remain alive. |
| `binary_e2e_orchestrator_sigkilled_children_die_within_timeout` | If `pp-smoke-run` itself is killed mid-run, its child `pp-gpu-node` processes die within 10s (the `ChildGuard` `Drop` invariant generalised). |
### 13.3 Boot-order edge cases
| Test name | Proves |
|---|---|
| `binary_e2e_orchestrator_can_resolve_pp_entry_after_n_stages_register` | At `N=5`, `pp-entry` resolves at the orchestrator only after every stage has registered its per-index name (no premature `pp-entry` visibility). |
| `binary_e2e_pp_smoke_run_handles_slow_middle_stage_boot` | At `N=4`, simulated boot delay of 30s on a middle stage does not cause the orchestrator to time out on convergence (within the 90s budget). |
| `binary_e2e_pp_smoke_run_handles_slow_last_stage_boot` | Same as above, but the slow boot is on the last stage. |
### 13.4 Gate procedure
A clean pass of every test in §13.1 and §13.2 at `N=5` is the
**necessary precondition for invoking `pp-smoke-run --vastai`**. CI
enforces this by gating §14 on §13 passing.
---
## 13b. Docker-Coordinated Localised E2E — `tests/t_docker.rs`
**The Stage 11 pre-deploy gate.** Same shape as §13, but each
`pp-gpu-node` runs inside its own Docker container on `--network host`
instead of as a bare host process. Validates that the image, the
shim, and the orchestrator compose into a single command that brings
the cluster up, drives one request, and tears everything down — the
last test before the manual vast.ai run in §15. `#[ignore]`d;
requires a working Docker daemon. Run with:
```text
cargo test -p pipeline-parallel-inference --test t_docker -- --ignored
```
### 13b.1 Happy paths
| Test name | Proves |
|---|---|
| `docker_e2e_three_stage_cluster_returns_response` | `scripts/docker-e2e.sh 3` (builds the stub image, spawns 3 containers, drives one request, tears down) exits 0 with a non-empty response and no leftover containers. The single-command gate the plan calls out. |
| `docker_e2e_re_running_command_twice_both_pass` | Running the same script twice in a row both pass. Catches state that leaks across runs (cached container names, lingering iroh ports, image build flakes). |
### 13b.2 Failure / cleanup paths
| Test name | Proves |
|---|---|
| `docker_e2e_premature_container_exit_fails_fast` | Killing one stage container mid-decode (`docker kill pp-stage-1`) causes `pp-smoke-run` to exit non-zero within 60s and leave no surviving stage containers. |
### 13b.3 Gate procedure
A clean pass of every §13b test is the **necessary precondition for
invoking `pp-smoke-run --vastai`** — it proves the deployable
container image and the orchestrator coordinate correctly end-to-end
on localhost. The manual vast.ai run in §15 must not be attempted
until both §13 and §13b are green.
---
## 14. Real-Tinygrad Binary E2E (localhost) — `tests/t_binary.rs` (gated)
Surface: same binaries as §13, but the real tinygrad worker on CPU
with the real `llama3.2:1b` GGUF. `#[ignore]`. Takes minutes per
case. Closes the gap between "stubs everywhere" and "real model
weights split across stages."
| Test name | Proves |
|---|---|
| `binary_e2e_real_tinygrad_two_stage_returns_response` | `pp-smoke-run --seed --num-stages 2` with the real worker prints a non-empty response and exits 0. |
| `binary_e2e_real_tinygrad_three_stage_returns_response` | Same at `N=3` — exercises one middle stage on real weights. |
| `binary_e2e_real_tinygrad_four_stage_returns_response` | Same at `N=4` — exercises two middle stages. |
| `binary_e2e_real_tinygrad_response_matches_single_node_for_say_hello` | At `N=3`, the printed response equals the single-node `Transformer.generate()` output on `"Say hello"`. This is the §12 equivalence test, escalated through the full binary. |
---
## 15. Manual Smoke Run — not a test, documented for completeness
Surface: actual rental on vast.ai. Not run by CI. Documented as a
runbook command and a pass criterion.
| Procedure | Pass criterion |
|---|---|
| `VAST_API_KEY=... pp-smoke-run --vastai --num-stages 5 --gpu "RTX 4090"` | Exits 0 within 15 minutes; prints a non-empty response; all 5 rented instances appear destroyed in the vast.ai dashboard. |
**Preconditions before invoking:**
1. Every test in §13.1 and §13.2 at `N=5` passes locally — the
binary-vs-binary gate.
2. Every test in §13b passes locally — the container-vs-container
gate, which proves the actual image we ship to vast.ai boots and
converges in `--network host` mode.
These are the two explicit pre-deploy gates. CI gates §14 on §13;
§13b is gated by the executing developer because it requires a Docker
daemon and an image build.
---
## Out of Scope (Tests We Are Not Writing)
* Performance, latency, throughput tests. The N-stage spec declares
no perf targets — those are rung 7.
* Fault-tolerance recovery (retry, rescheduling, re-election). Out
of scope for this rung; rung 6.
* Heterogeneous-split correctness (stages with unequal block counts).
The planner (rung 5) introduces this; the equivalence test in §12
asserts only that the *even* split matches the single-node output.
* Streaming token tests. No streaming API.
* Sampling correctness beyond argmax determinism. No temperature,
top-p, etc.
* Concurrent in-flight requests. Single-request only.
* `N = 1` configurations. Use `examples/single-gpu-inference`.
* `N > 8` configurations. Not in the test matrix; no acceptance
criterion is defined past `N=5`. Adding `N=8+` is a deliberate
decision keyed to the planner / fault-tolerance rungs.
* Tests of reused-as-is components from `single-gpu-inference` (iroh
transport mechanics, swactor process bridge mechanics,
single-instance vast.ai operations). They are tested in that
example and are not duplicated here.