Module 12. RL infrastructure for LLMs: memory, rollouts, and asynchrony
Try each exercise first. Expand worked answers or project guidance when needed; their open state is shared between RU/EN.
Download course files (.zip) 382.1 KBThe archive preserves the source folder structure, both languages, and supporting files.
Logical roles and processes
In one experiment, a single process alternates among generation, reward computation, and an optimizer step. In another, four services perform these operations.
-
Name the four logical roles in the RL loop.
-
Must they be four separate processes?
-
Which data invariant must survive every placement?
-
Why are an answer string and one reward number an insufficient rollout contract?
Show answer and solution
The four roles are:
-
the trainer, which computes losses, gradients, and optimizer steps;
-
the rollout engine, which samples policy actions and owns the KV cache;
-
the verifier or environment, which produces an outcome and operational status;
-
the orchestrator, which selects prompts, assembles groups, and manages queues, versions, and recovery.
They need not be separate processes. One GPU may alternate among all roles, while one logical role may span many workers on a cluster.
The invariant is that each training record unambiguously associates actions with the behavior policy that produced them and reward with the verifier or environment version that produced it.
A string and scalar reward omit tokenization, the policy-token mask, behavior log-probabilities, weight version, decoding parameters, stop reason, and reward provenance. The loss and the failure cannot be reconstructed reliably.
Two independent axes
A system uses a separate vLLM server. The trainer requests one batch, waits for the complete batch, trains, synchronizes weights, and only then requests the next batch.
-
Is placement colocated or disaggregated?
-
Is the logical algorithm synchronous or asynchronous?
-
Why is “a separate server automatically makes the algorithm asynchronous” false?
-
Give one example of colocation with buffering and one example of disaggregation with a strict barrier.
Show answer and solution
-
Placement is disaggregated because generation runs on a separate server.
-
The logical protocol is synchronous because every batch ends at a barrier.
-
Asynchrony is defined by production and consumption order, not by a network address. A separate server can serve a strictly sequential loop.
-
Colocation with buffering: one graphics processing unit (GPU) alternates between rollout and training while the CPU prepares the next prompt batch. Disaggregation with a barrier: a dedicated vLLM pool generates a batch, then waits until training and weight loading finish.
Memory profile
A colleague says, “Adam always needs 16 bytes per parameter, so no other accounting is required.”
-
Explain which objects may make up these 16 bytes.
-
Name at least four reasons the actual profile can differ.
-
Why does dividing logical model-state memory by the number of Fully Sharded Data Parallel (FSDP) participants not give the exact peak?
-
What does LoRA change, and what does it not remove?
Show answer and solution
A common 16-byte profile is:
- 2 bytes for a bfloat16 parameter;
- 2 bytes for a bfloat16 gradient;
- 8 bytes for two float32 Adam moments;
- 4 bytes for a separate float32 master copy.
The profile changes with gradient precision, whether a master copy exists, optimizer type, state quantization, CPU offload, adapter-only training, sharding, and implementation details.
Dividing by the FSDP group size ignores replicated objects, temporary module gathering, communication buffers, activations, logits, allocator fragmentation, and transfer phases.
LoRA reduces parameters with gradients and optimizer state. It does not remove base weights, forward activations, the KV cache, temporary buffers, or the requirement to publish a consistent adapter version to the rollout engine.
KV cache and rollout length
The Group Relative Policy Optimization (GRPO) group size is increased from 8 to 32 with the same maximum completion length.
-
How does useful key–value cache (KV-cache) memory change if all sequences are active concurrently?
-
Why may observed memory growth differ from the ideal linear formula?
-
Name two engine mechanisms that reduce fragmentation or repeated work but do not eliminate stored keys and values.
-
Why can a shared-prefix cache not be reused across a weight update without validation?
Show answer and solution
-
For Group Relative Policy Optimization (GRPO), useful payload grows by a factor of four when concurrent sequences grow from 8 to 32.
-
Actual growth depends on early completion, length distribution, allocator pages, tensor parallelism, reserve, and prefix reuse.
-
PagedAttention reduces managed-cache fragmentation; prefix caching or RadixAttention reuses the common prompt prefix.
-
Keys and values depend on model parameters. A cache computed by the old weights is not a cache of the new policy.
Atomic policy version
A long completion starts under version 120. Halfway through, the engine loads version 121. The log stores only policy_version=121 and the final text.
-
What is wrong with this contract?
-
Name two safe update regimes.
-
What data are required if mid-completion updates are allowed anyway?
-
Why do identical weights with different
top_por temperature still define different behavior policies?
Show answer and solution
The record assigns one version to a completion generated by a mixture of two weight sets. The behavior-policy denominator and replay semantics are therefore ambiguous.
Safe regimes include:
- let in-flight completions finish under version 120 and switch only new requests;
- record a version and behavior log-probabilities per action or turn when version changes are an explicit part of the protocol.
A mid-completion update requires token-level versions, actual log-probabilities after decoding transforms, an atomic update order, and an action mask.
Temperature, top-p, and logit masks transform the distribution after the weights. Identical transformers can therefore define different behavior policies.
Lag and mismatch
Immediately after synchronization, the rollout engine and trainer hold the same weight version, but their token log-probabilities differ slightly. Ten optimizer steps later, the queue contains rollouts that are eight versions old.
-
What is the first problem called?
-
What is the second problem called?
-
Which control experiment separates the two causes?
-
Why is effective sample size (ESS) a diagnostic rather than proof that an update is unbiased?
Show answer and solution
The first problem is training–inference mismatch (TIM): the declared version is the same, but numerical probabilities differ.
The second is policy lag: the data were generated by an older version.
The control is a zero-lag comparison immediately after synchronization: score the same token sequence with the engine and trainer and compare token log-probabilities. Then vary version age separately.
ESS measures weight concentration. It does not prove that the denominator is correct, clipping is unbiased, masks are valid, or the chosen off-policy estimator matches the target objective.
Queue and long tails
Rollout workers produce 14 million tokens per minute, while the trainer sustainably consumes 10 million. At the same time, the 99th percentile (p99) of rollout duration is six times the median.
-
Which first-order queue-stability condition is violated?
-
What is backpressure in this system?
-
Why is a limit on record count weaker than a limit on tokens or estimated cost?
-
Why can unconditional removal of the slowest episodes shift the training distribution?
Show answer and solution
-
The condition is violated: 14 million tokens/min arrive and only 10 million are consumed.
-
Backpressure slows or pauses rollout workers when a queue threshold is reached.
-
Records can differ by orders of magnitude in length. Record count is a weak proxy for memory, compute, and age; token or cost limits follow physical load more closely.
-
Long episodes can be systematically harder. Removing them shifts the task distribution toward short and easy cases.
Checkpoint and framework choice
A team saves only model.safetensors, then restarts with a zeroed optimizer and a new verifier version. The report calls this a continuation of the same run.
-
Why is that inaccurate?
-
List at least eight components of a semantically complete RL checkpoint.
-
How should recovery be tested before a real failure?
-
Why cannot TRL, verl, OpenRLHF, NeMo RL, PRIME-RL, SkyRL, and AReaL be placed on one “better–worse” line without a task contract?
Show answer and solution
This is a new run initialized from old weights, not a continuation of the previous optimization process. Adam moments, scheduler state, and reward contract changed.
A semantically complete checkpoint commonly includes:
- model and adapter weights;
- optimizer state;
- learning-rate scheduler;
- global step and policy version;
- random-number generator states;
- dataset cursor;
- curriculum or prompt-scheduler state;
- reward and advantage normalization;
- tokenizer and chat-template revisions;
- verifier and environment versions;
- decoding configuration;
- rollout schema;
- distributed layout and queue-recovery policy.
Recovery should be tested by saving, terminating processes deliberately, restoring, executing a control batch, and checking versions, tokenization, finite losses, and expected statistics.
Frameworks optimize different levels: a ready-made trainer, role orchestration, a fully asynchronous dataflow, agent environments, or a particular distributed backend. There is no one-dimensional ranking without model size, scale, synchrony, environment type, fault-tolerance requirements, and team expertise.
Full training and adapters
A model has parameters. Base weights are bfloat16, gradients are bfloat16, two Adam moments are float32, and a separate master copy is float32.
-
Find bytes per trainable parameter.
-
Find logical full-training memory in GB.
-
Find memory of base bfloat16 weights alone in GB.
-
LoRA adds and trains adapter parameters while all base weights remain resident. Find logical memory with the same adapter-state profile.
-
Why is none of these values the GPU-memory peak?
Show answer and solution
Bytes per trainable parameter are
Full training requires
Base bfloat16 weights require
With LoRA, base weights still take 14 GB. Each of 20 million trainable parameters requires the adapter weight itself, a gradient, two moments, and a master copy:
The extra amount is
for a total of
The peak also contains activations, temporary buffers, logits, KV cache, fragmentation, replicas, and weight-transfer phases.
KV cache
Use the teaching architecture
-
How many bytes do keys and values require for one cached token?
-
How many KiB is that?
-
Find memory for 64 sequences of 4096 tokens.
-
Express the result in GiB.
-
What is the idealized local payload under uniform KV sharding across four tensor-parallel participants?
Show answer and solution
One cached token requires
That is
The total token count is
Memory is
Ideal uniform sharding across four participants gives
This is useful payload without pages, block tables, alignment, or temporary tensors.
Sequential and overlapped pipeline
Generation plus verification takes s per batch. Training plus synchronization takes s.
-
Find the duration of one sequential batch.
-
Find the ideal two-stage steady-state period.
-
Find total time for 20 sequential batches.
-
Find total time for 20 batches in the ideal pipeline.
-
Find full-run speedup and asymptotic period speedup.
-
Why is the speedup far below two?
Show answer and solution
One sequential batch takes
The steady-state period is
Twenty sequential batches take
The ideal pipeline takes
Full-run speedup is
and asymptotic period speedup is
The stages are unbalanced. Ten seconds of training cannot hide 90 seconds of rollout work.
A slow rollout
Eight concurrent rollouts take
-
Find total work in GPU-seconds.
-
Find synchronous group time.
-
Find rectangle capacity: eight workers times group time.
-
Find utilization and idle fraction.
-
How does utilization change if the 20-second rollout can safely be truncated at 8 seconds? Why is such truncation not always statistically neutral?
Show answer and solution
Total work is
The synchronous group ends after
Capacity is
Utilization is
and idle fraction is
After safely truncating the final rollout at eight seconds, work is 43 GPU-s, wall time is 8 s, and capacity is 64 GPU-s:
Utilization improves, but if long episodes are systematically hard, truncation changes the observed task and reward distribution.
Probability ratios and ESS
For four sequences, log importance ratios are
-
Find .
-
Find ESS.
-
Find normalized ESS.
-
Compare with four equal weights.
-
What changes if the final log ratio is clipped above at ?
-
What do these values reveal, and what do they not prove?
Show answer and solution
Weights are
Their sum is 13 and squared sum is
Thus
Normalized ESS is
Four equal weights have ESS 4 and normalized ESS 1.
After clipping the final log ratio at , weights are :
with normalized ESS about 0.942.
Clipping reduces concentration but changes the estimator. ESS describes weight geometry; it does not prove that the objective or policy quality is correct.
Synchronization and checkpoints
Weight synchronization takes 8 seconds. In a teaching approximation, one unit of mean lag costs one second per update. Candidate synchronization intervals are , with
-
Compute for all candidates and choose the minimum.
-
What are maximum and stationary mean lag at the selected ?
-
For ten updates, write the exact lag sequence and its mean.
Separately, a checkpoint takes s and mean time between failures is s.
- Find the approximate optimum
-
Convert it to minutes.
-
Which important costs are absent from this model?
Show answer and solution
| 1 | 8.0 | 0.0 | 8.0 |
| 2 | 4.0 | 0.5 | 4.5 |
| 4 | 2.0 | 1.5 | 3.5 |
| 8 | 1.0 | 3.5 | 4.5 |
| 16 | 0.5 | 7.5 | 8.0 |
The minimum among these candidates is .
The stationary lag cycle is , so maximum lag is 3 and mean lag is
For ten updates:
with exact finite-run mean .
The checkpoint interval is
The model omits restart time, asynchronous writes, correlated failures, storage and network cost, partially lost queues, distributed-layout incompatibility, and time-varying risk.
Model-state and KV-cache memory
Implement:
def model_state_bytes(
total_parameters: int,
trainable_parameters: int,
parameter_bytes: int,
gradient_bytes: int,
optimizer_state_bytes: int,
master_parameter_bytes: int,
) -> int:
...
def kv_cache_bytes(
num_sequences: int,
tokens_per_sequence: int,
num_layers: int,
num_kv_heads: int,
head_dim: int,
element_bytes: int,
) -> int:
...Validate dimensions, require 0 <= trainable_parameters <= total_parameters, and reproduce B1–B2.
Open checks:
profile = model_state_bytes(
7_000_000_000,
trainable_parameters=7_000_000_000,
parameter_bytes=2,
gradient_bytes=2,
optimizer_state_bytes=8,
master_parameter_bytes=4,
)
assert profile == 112_000_000_000
lora = model_state_bytes(
7_020_000_000,
trainable_parameters=20_000_000,
parameter_bytes=2,
gradient_bytes=2,
optimizer_state_bytes=8,
master_parameter_bytes=4,
)
assert lora == 14_320_000_000
assert lora < profileShow answer and solution
def model_state_bytes(
total_parameters,
trainable_parameters,
parameter_bytes,
gradient_bytes,
optimizer_state_bytes,
master_parameter_bytes,
):
integers = [
total_parameters,
trainable_parameters,
parameter_bytes,
gradient_bytes,
optimizer_state_bytes,
master_parameter_bytes,
]
if any(isinstance(x, bool) or not isinstance(x, int) for x in integers):
raise ValueError("all arguments must be integers")
if total_parameters <= 0 or not 0 <= trainable_parameters <= total_parameters:
raise ValueError("invalid parameter counts")
if min(integers[2:]) < 0:
raise ValueError("byte counts must be non-negative")
base = total_parameters * parameter_bytes
trainable_extra = trainable_parameters * (
gradient_bytes + optimizer_state_bytes + master_parameter_bytes
)
return base + trainable_extra
def kv_cache_bytes(
num_sequences,
tokens_per_sequence,
num_layers,
num_kv_heads,
head_dim,
element_bytes,
):
values = [
num_sequences,
tokens_per_sequence,
num_layers,
num_kv_heads,
head_dim,
element_bytes,
]
if any(isinstance(x, bool) or not isinstance(x, int) or x <= 0 for x in values):
raise ValueError("all dimensions must be positive integers")
return (
2
* num_sequences
* tokens_per_sequence
* num_layers
* num_kv_heads
* head_dim
* element_bytes
)assert model_state_bytes(7_000_000_000, 7_000_000_000, 2, 2, 8, 4) == 112_000_000_000
assert model_state_bytes(7_020_000_000, 20_000_000, 2, 2, 8, 4) == 14_320_000_000
assert kv_cache_bytes(64, 4096, 32, 8, 128, 2) == 32 * 1024**3Pipeline time
Implement:
def pipeline_report(rollout_seconds: float, train_seconds: float, batches: int) -> dict[str, float]:
"""Return sequential_total, pipeline_total, period, and speedup."""For , , and , return 2000 seconds sequentially and 1810 seconds in the ideal pipeline.
Open checks:
pipe_one = pipeline_report(90.0, 10.0, 1)
assert pipe_one["pipeline_total"] == 100.0
assert pipe_one["speedup"] == 1.0Show answer and solution
def pipeline_report(rollout_seconds, train_seconds, batches):
if rollout_seconds <= 0 or train_seconds <= 0:
raise ValueError("stage times must be positive")
if isinstance(batches, bool) or not isinstance(batches, int) or batches <= 0:
raise ValueError("batches must be a positive integer")
sequential = batches * (rollout_seconds + train_seconds)
period = max(rollout_seconds, train_seconds)
pipelined = rollout_seconds + train_seconds + (batches - 1) * period
return {
"sequential_total": sequential,
"pipeline_total": pipelined,
"period": period,
"speedup": sequential / pipelined,
}Importance weights and ESS
Implement:
def importance_weights(current_logprobs, behavior_logprobs, max_log_ratio=None):
...
def effective_sample_size(weights, normalized=False):
...Requirements:
- inputs are non-empty one-dimensional arrays of equal length;
- log-probabilities are finite and non-positive;
- weights are non-negative;
- zero squared-weight sum raises
ValueError; - optional clipping happens in log-ratio space.
Open checks:
new = np.array([-0.4, -0.6])
old = np.array([-0.5, -0.5])
w = importance_weights(new, old)
assert np.allclose(w, np.exp(new - old))
assert np.allclose(importance_weights(old, old), 1.0)Show answer and solution
import numpy as np
def importance_weights(current_logprobs, behavior_logprobs, max_log_ratio=None):
current = np.asarray(current_logprobs, dtype=float)
behavior = np.asarray(behavior_logprobs, dtype=float)
if current.ndim != 1 or current.shape != behavior.shape or current.size == 0:
raise ValueError("inputs must be non-empty 1-D arrays of equal shape")
if not np.isfinite(current).all() or not np.isfinite(behavior).all():
raise ValueError("log probabilities must be finite")
if np.any(current > 0) or np.any(behavior > 0):
raise ValueError("log probabilities must be non-positive")
log_ratio = current - behavior
if max_log_ratio is not None:
if not np.isfinite(max_log_ratio):
raise ValueError("max_log_ratio must be finite")
log_ratio = np.minimum(log_ratio, max_log_ratio)
return np.exp(log_ratio)
def effective_sample_size(weights, normalized=False):
w = np.asarray(weights, dtype=float)
if w.ndim != 1 or w.size == 0 or not np.isfinite(w).all():
raise ValueError("weights must be a finite non-empty vector")
if np.any(w < 0):
raise ValueError("weights must be non-negative")
denom = float(np.dot(w, w))
if denom == 0:
raise ValueError("at least one weight must be positive")
ess = float(w.sum() ** 2 / denom)
return ess / w.size if normalized else essRollout-contract validation
Return a list of errors for a record with required fields:
sample_id, prompt_id, policy_version,
prompt_tokens, response_tokens, behavior_logprobs,
decoding, reward, stop_reason, verifier_versionValidate types, non-negative versions and token IDs, finite log-probabilities, and alignment with response_tokens.
Signature:
def rollout_contract_errors(record: Mapping[str, Any]) -> list[str]:
...Connection to earlier practice. Compare this single-completion record with the multi-turn trajectory from Module 11, Task C5 (
validate_rollout_record). Reuse validation rules, but explicitly adapt the schema: adding onlysample_id,verifier_version, anddecodingis insufficient. Describe the field mapping and the information that must remain in the original agent log.
Open checks:
record = {
"sample_id": "s1", "prompt_id": "q1", "policy_version": 1,
"prompt_tokens": [1, 2], "response_tokens": [3, 4],
"behavior_logprobs": [-0.1, -0.2], "decoding": {"temperature": 1.0},
"reward": {"task": 1.0}, "stop_reason": "eos", "verifier_version": "v1",
}
assert rollout_contract_errors(record) == []
assert rollout_contract_errors({}) != []
assert any("missing fields" in e for e in rollout_contract_errors({}))Show answer and solution
import math
REQUIRED = {
"sample_id", "prompt_id", "policy_version",
"prompt_tokens", "response_tokens", "behavior_logprobs",
"decoding", "reward", "stop_reason", "verifier_version",
}
def rollout_contract_errors(record):
if not isinstance(record, dict):
return ["record must be a dict"]
errors = []
missing = sorted(REQUIRED - set(record))
if missing:
errors.append("missing fields: " + ", ".join(missing))
version = record.get("policy_version")
if isinstance(version, bool) or not isinstance(version, int) or version < 0:
errors.append("policy_version must be a non-negative integer")
for key in ("prompt_tokens", "response_tokens"):
tokens = record.get(key)
if not isinstance(tokens, list) or any(
isinstance(token, bool) or not isinstance(token, int) or token < 0
for token in (tokens or [])
):
errors.append(f"invalid {key}")
logps = record.get("behavior_logprobs")
if not isinstance(logps, list) or any(
not isinstance(value, (int, float))
or not math.isfinite(value)
or value > 0
for value in (logps or [])
):
errors.append("invalid behavior_logprobs")
elif isinstance(record.get("response_tokens"), list) and len(logps) != len(record["response_tokens"]):
errors.append("logprobs do not align with response_tokens")
return errorsIn Module 11, token_ids, policy_mask, and turn_ids describe a whole trajectory, while policy_versions identifies its turns. Here select one completion generated by one behavior-policy version: prompt_tokens is its generation context, response_tokens contains the policy-selected tokens in that completion, and behavior_logprobs aligns with those tokens. The single numeric policy_version requires an explicit version registry; do not silently coerce a version string into a number. Assign prompt_id when constructing the prompt. Mapping rewards to reward and choosing stop_reason require declared credit-assignment and stopping rules for this completion; do not automatically copy the episode outcome to every turn. Environment IDs, actions, observations, token provenance, and trajectory-level flags remain in the original log linked to the infrastructure record. This is a projection onto a different data unit, not a replacement for the full agentic contract.
Periodic synchronization and checkpoints
Implement:
def synchronization_schedule(sync_every: int, updates: int) -> list[int]:
"""Return 0, 1, ..., sync_every-1 with periodic reset."""
def optimal_checkpoint_interval(checkpoint_seconds: float, mtbf_seconds: float) -> float:
...Reproduce B6 and test sync_every=1 and updates=1.
Open checks:
schedule = synchronization_schedule(4, 10)
assert schedule == [0, 1, 2, 3, 0, 1, 2, 3, 0, 1]Show answer and solution
from math import sqrt
def synchronization_schedule(sync_every, updates):
if isinstance(sync_every, bool) or not isinstance(sync_every, int) or sync_every <= 0:
raise ValueError("sync_every must be positive")
if isinstance(updates, bool) or not isinstance(updates, int) or updates <= 0:
raise ValueError("updates must be positive")
return [step % sync_every for step in range(updates)]
def optimal_checkpoint_interval(checkpoint_seconds, mtbf_seconds):
if checkpoint_seconds <= 0 or mtbf_seconds <= 0:
raise ValueError("times must be positive")
return sqrt(2 * checkpoint_seconds * mtbf_seconds)Diagnosis from measurements
Implement a rule that receives
metrics = {
"trainer_idle_fraction": ...,
"rollout_idle_fraction": ...,
"verifier_fraction": ...,
"weight_sync_fraction": ...,
"queue_growth_per_minute": ...,
"normalized_ess": ...,
"oom_events": ...,
}and returns testable hypotheses, not final diagnoses. For example:
- high trainer idle time with an empty queue → inspect rollout, verification, and prompt supply;
- high synchronization fraction → measure transfer and weight conversion;
- low ESS → separate policy lag from training–inference mismatch;
- out-of-memory events → build a phase-specific memory ledger.
Add range checks and a neutral test where no dominant bottleneck is identified.
Open checks:
neutral = diagnose_pipeline({
"trainer_idle_fraction": 0.05, "rollout_idle_fraction": 0.05,
"verifier_fraction": 0.05, "weight_sync_fraction": 0.05,
"queue_growth_per_minute": 0.0, "normalized_ess": 0.9, "oom_events": 0,
})
assert neutral == ["inspect a stage-level trace"]
starved = diagnose_pipeline({
"trainer_idle_fraction": 0.6, "rollout_idle_fraction": 0.05,
"verifier_fraction": 0.1, "weight_sync_fraction": 0.05,
"queue_growth_per_minute": 0.0, "normalized_ess": 0.9, "oom_events": 0,
})
assert len(starved) == 1Show answer and solution
def diagnose_pipeline(metrics):
required = {
"trainer_idle_fraction",
"rollout_idle_fraction",
"verifier_fraction",
"weight_sync_fraction",
"queue_growth_per_minute",
"normalized_ess",
"oom_events",
}
missing = required - set(metrics)
if missing:
raise ValueError("missing metrics: " + ", ".join(sorted(missing)))
bounded = required - {"queue_growth_per_minute", "oom_events"}
for key in bounded:
if not 0 <= metrics[key] <= 1:
raise ValueError(f"{key} must lie in [0, 1]")
if metrics["oom_events"] < 0:
raise ValueError("oom_events must be non-negative")
hypotheses = []
if metrics["trainer_idle_fraction"] > 0.25 and metrics["queue_growth_per_minute"] <= 0:
hypotheses.append("check rollout, verifier, and input supply")
if metrics["verifier_fraction"] > 0.30:
hypotheses.append("profile the verifier critical path")
if metrics["weight_sync_fraction"] > 0.20:
hypotheses.append("profile weight transfer and conversion")
if metrics["normalized_ess"] < 0.50:
hypotheses.append("separate policy lag from training-inference mismatch")
if metrics["oom_events"] > 0:
hypotheses.append("measure phase-specific peaks, KV cache, activations, and transfer buffers")
if not hypotheses:
hypotheses.append("inspect a stage-level trace")
return hypothesesThe important design choice is that outputs are hypotheses that can be tested with a targeted measurement. A single aggregate metric rarely identifies a root cause.