6.7 KiB
6.7 KiB
T5.2 — CapacityLimits and the residency invariant
| Field | Value |
|---|---|
| Phase | P5 — Grading |
| Size | M — 1 to 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | T5.3 |
Goal
Declared VRAM limits, the residency invariant, and a max_context_tokens that is
derived from them rather than configured beside them.
Facts (inlined — no spec read needed)
pub struct CapacityLimits {
/// Per-device VRAM this framework may use. Not the card's total — leave
/// headroom for anything else sharing the device.
pub vram_bytes_per_device: u64,
pub devices: u32,
/// Hard cap on simultaneously resident models across all devices.
pub max_resident_models: u32,
/// Never evictable. The agent's model belongs here; if it can be evicted,
/// grading can stall agent work, which the first principle forbids.
pub pinned: Vec<ModelId>,
}
pub struct ModelProfile {
pub id: ModelId,
pub weights_bytes: u64,
/// KV cache cost per token at the deployed dtype and parallelism.
pub kv_bytes_per_token: u64,
/// Devices this model spans under tensor parallelism.
pub devices_required: u32,
}
Invariant, checked at load and before any admission:
sum(weights of resident models) + peak_concurrent_kv ≤ vram_bytes_per_device × devices
resident_model_count ≤ max_resident_models
Derived ceiling — never a separate config knob:
max_context_tokens = (vram_bytes_per_device × devices − sum(weights)) / kv_bytes_per_token
- Metering counts tokens after the fact; on self-hosted weights the binding limit arrives earlier and harder. A model that is not resident cannot be inferred against, and making it resident means evicting something else and paying a load measured in tens of seconds.
- One resident model is the default configuration. The agent's model is pinned; the judge and the proposer run on that same model. A strategy naming a second model is legal and is rejected at load unless the invariant holds with both resident — never by swapping between them per call.
- The worked case, because the result is not marginal: at GQA fp16, per-token KV runs roughly 0.13 MB for an 8B-class model and 0.33 MB for a 70B-class one. Two episodes at a 200k-token retention ceiling is 400k tokens of context — 52 GB of KV cache at 8B, 131 GB at 70B, before weights. Neither fits an 80 GB device. A judge reading two full-ceiling episodes is not expensive, it is impossible.
- Therefore the retention ceiling is derived from this, not set beside it: the
reduction target is
max_context_tokens / 2, and where that is smaller than the configured ceiling, the bound wins. - Distributed GPUs change the arithmetic, not the rule.
devices_requiredexpresses tensor parallelism;max_resident_modelsis a fleet-wide count, so two nodes each holding the agent model are two resident instances, not one. kv_bytes_per_tokenvaries with dtype, quantization, attention implementation and parallelism. It is measured per deployment or read from an operator-supplied profile — the framework refuses a guess.
Steps
- Define
CapacityLimitsandModelProfileas above. NoDefaultforkv_bytes_per_token— an absent value is a load error, not a guess. - Write
check_residency(&CapacityLimits, &[ModelProfile]) -> Result<(), CapacityError>implementing both inequalities.CapacityErrornames the model and the shortfall in bytes. - Write
derive_max_context_tokens(...)from the formula above. Expose it as a function, not a settable field. - Call the check at load, and again before admission (T8.6 enforces the runtime half).
- Refuse a context request over the derived ceiling with the computed limit in the error message.
- Encode the worked case as a test: 70B-class profile, 0.33 MB/token, 80 GB device, two 200k-token episodes → refused.
Acceptance
- A config whose resident set exceeds VRAM is rejected at load, naming the model and the shortfall — not at first inference.
- A judge context request exceeding the derived ceiling is refused with the computed limit in the error.
- The worked case asserts the arithmetic: 70B-class, 0.33 MB/token, 80 GB device, two 200k-token episodes must be refused.
Verify
Harness: pure arithmetic — no GPU required. Model profiles as fixtures with
declared weights_bytes and kv_bytes_per_token.
Integration test — tests/it_capacity_invariant.rs:
- The worked case, hardcoded: 70B-class profile,
kv_bytes_per_token = 0.33 MB, one 80 GB device. Two 200k-token episodes = 400k tokens = 131 GB of KV before weights. Assert the request is refused and that the error carries the computed limit. - Same arithmetic at 8B / 0.13 MB per token: 52 GB. Assert also refused on an 80 GB device once weights are counted.
- Over-capacity config at load: resident set exceeding VRAM → rejected at load, error naming the model and the byte shortfall. Assert the message contains both.
- Assert
max_context_tokensis only reachable as a function, not a settable field —trybuildif it is a private field with no setter. - Multi-device:
devices_required = 4under tensor parallelism; assert the invariant usesvram × devicesand thatmax_resident_modelsis counted fleet-wide, so the same model on two nodes counts as two. - Missing
kv_bytes_per_token→ load error, not a default. Assert noDefaultimpl exists. - Pinned model: assert no code path can evict a pinned model to satisfy the invariant — the eviction candidate list excludes pinned entries.
Command: cargo test -p capacity invariant
False pass:
- Asserting only that "some error" is returned. The acceptance criterion is that the arithmetic is right — pin the expected byte figures in the test, so an off-by-a-factor error in the KV formula is caught rather than rounded away.
- Step 3 checked at first inference rather than at load. Both produce an error; only one produces it before 3am.
- Testing with a permissive config where everything fits. Every implementation passes that.
Traps
- A
max_context_tokensconfig field. It will be set to something plausible and discovered wrong at the first judge call. - Treating "call the judge model" as equivalent in cost to "call the agent model". On this hardware that is wrong by two orders of magnitude.
- Allowing an eviction of a pinned model to satisfy the invariant.
Background (not required to do this task): rust-agentic-sys.md §8.6, §14.2, §18 · rust-agentic-task.md