opencoti-llamafile / docs /features /polykv_api.md
ManniX-ITA's picture
mirror: patches 0138-0144 (backfill c5 gap + c6) + full docs re-sync
327bdb9 verified
|
Raw
History Blame Contribute Delete
41.7 kB
# PolyKV Control-Plane API β€” Design (F5 M6-S5 / #712)
**Status:** design (2026-07-16). Implementation is phased (Β§10); nothing here
is shipped yet.
**Inputs:** [polykv_api_research.md](polykv_api_research.md) (design-space
survey), [poly_kv.md](poly_kv.md) (the KV mechanism this sits on top of),
[llamafile-usage.md](../llamafile-usage.md) Β§3.4 (SharedKVPool user surface).
**Single rule:** additive / opt-in. Every endpoint and field lives under the
existing `opencoti` JSON namespace or new `/polykv/*` routes; no upstream
behavior changes when PolyKV control is unused.
---
## 1. Goals
A rich, opt-in control plane that satisfies every multi-agent serving journey
on top of the existing SharedKVPool (`shared_pool_slot` +
`shared_prefix_n_tokens`, server-context.cpp:3085). Four requirements, plus
two the requirements imply:
1. **Full status** of pools + the sessions attached to each.
2. **tps reporting** β€” aggregate system throughput *and* per-session
generation tps. (All-new: upstream llama.cpp reports per-*response*
`timings` but has no per-session/slot running instrumentation.)
3. **Autonomous capacity/admission loop** β€” a main session sets a per-session
gen-tps target (e.g. 5 tps); when the average across active sessions
approaches the floor the orchestrator holds off spawning sub-agents.
Behavioral knobs: reject a new session vs. warn.
4. **LangGraph extensions** β€” a custom `BaseChatModel`, the pool-control API
exposed as LangGraph tools, and PolyKV-aware agent-state routing.
5. **Nesting** (implied by 3): a sub-agent that discovers fan-out work can
become a pool parent for its own sub-agents, so parallelizable tasks run
in parallel instead of being forced sequential.
6. **Fork / branch** (COW): a sub-agent group that shares an *ancestor* prefix
but then diverges (different toolset/context) gets its own shared pool
branched at the divergence point.
## 2. Decisions locked (2026-07-16)
| # | Decision | Choice |
|---|----------|--------|
| API home | where the control plane lives | **Self-contained in the C++ engine** β€” extends `/props`, `/slots`, adds `/polykv/*`. Works standalone; no `opencoti-server` in the path. |
| Admission | enforce the tps floor or advise | **Both** β€” advisory (report) by default; optional per-pool enforced mode with `reject`\|`warn`\|`queue`. |
| Pool model | how pools are created | **Explicit first-class objects** β€” `create`/`fork`/`attach`/`release`, immutable from birth (Β§3.3). Contiguous-prefix contract checked at fork. Boilerplate hidden by the opencoti plugin. |
| Scope | deliverable shape | **Design the full surface now; implement in gated phases** (Β§10). |
| Fork | divergent child prefix | **First-class in v1** (copy-free COW, Β§3.2 / Β§7) β€” *not* deferred. |
**Defaulted (override any):** transport = HTTP on the engine + SSE for the
live tps stream (poll fallback); tenancy = single trust domain (no per-pool
ACL); tps = per-session *experienced* decode tps (EWMA) + aggregate decode
throughput, floor = mean of active sessions' experienced tps (prefill
excluded); nesting depth uncapped; interior pools pinned while any descendant
is attached + grace TTL; seq-ids for pools+leaves sized at boot.
---
## 3. Data model
### 3.1 A pool is a tree node
```
Pool {
id : int32 // seq_id in the unified cache
parent : int32 | ROOT // -1 for a root pool
branch_pos D : int32 // shared-ancestor length inherited from parent
own_range : [D, L) // tokens this pool materialized itself
prefix_len L : int32 // total shared prefix = D + |own_range|
tokens : [llama_token] // full prefix token array [0, L), host-side (auto-P + fork validation)
prefix_hash : [u64] // cumulative rolling hash per position (validates any D; ~8 B/token host RAM)
pin : bool // resident-pinned (auto while descendants attached)
children : [Pool] // interior pools branched/extended from this one
sessions : [Session] // leaf sharers attached at this pool's prefix
last_access : ts
}
Session { // a leaf = an ordinary decoding slot
id : int32 // seq_id
pool : int32 // the pool it attached to
suffix_range : [L, L+S) // its own private, mutable continuation
tps_ewma : f32 // experienced decode tps
ttft_ms, state, ...
}
```
The **root** pool (`parent = ROOT`, `D = 0`) is what today's
`shared_pool_slot` request already produces. Everything else is new.
### 3.2 Three edge types, one mechanism
A child pool inherits `[0, D)` from its parent and owns `[D, L)`. `D` selects
the edge type:
- **extend** β€” `D = parent.L`. Child = parent prefix **+** appended shared
tokens. This is *nesting*: a sub-agent adds its own shared context and hands
it to its sub-agents.
- **branch (COW fork)** β€” `D < parent.L`. Child shares the common ancestor
`[0, D)` and diverges with its own `[D, L)`. This is the fork: same system
prefix, different toolset after `D`.
- **root** β€” `parent = ROOT`, `D = 0`.
**Why the fork is copy-free.** `seq_cp` in the unified cache is additive
membership: for each cell where `seq_has(cell, parent)` in `[0, D)` it calls
`seq_add(cell, child)` (llama-kv-cache.cpp:2007-2044). No cell is duplicated β€”
the ancestor cells simply gain the child in their sequence set. Because
prefixes are **immutable** (FROZEN, Β§3.3), the shared range is never mutated,
so the "copy" a classic COW would eventually pay never happens. The child only
computes new KV for `[D, L)`, prefilled at RoPE position `D` attending to the
shared `[0, D)` already in cache β€” exactly how attaching with a shorter
`shared_prefix_n_tokens` works today. **Extend and branch are the same code
path parameterized by `D`.**
### 3.3 Immutability & versioning
**Pools are immutable from birth.** A pool's prefix is fully materialized at
`create`/`fork` and never changes afterwards β€” there is no OPEN/extend-in-place
state. "Extending" a pool *always* means creating a child (`fork` with
`D = parent.L`). This is simpler than a freeze-on-first-attach state machine
and loses nothing: the "build context incrementally" journey is covered by the
main agent decoding in its **own session** and then forking `from_session`
(Β§4.1), which snapshots its current context as a new pool in one call β€” no
in-place mutation ever needed.
This makes "add a tool to the shared prefix after agents attached"
well-defined β€” it is a *new versioned pool* (a fork), and the old one keeps
serving its sharers unchanged. (This is the precise answer to the HuggingFace
question: the prefix is static per pool; a tool/MCP added later is a new pool
version, valid only for sessions that attach to it β€” never mutated under
sessions already on the old one.)
### 3.4 Lifetime
Cell freeing is implicit-refcounted: a cell is reclaimed only when its sequence
set empties, so an ancestor's cells physically survive as long as *any*
descendant references them β€” even if the ancestor's own slot is torn down. On
top of that:
- **Pin policy.** Interior pools are auto-pinned (`--no-clear-idle` semantics,
per-node) while any descendant is attached. Without this a new leaf attaching
to an idle-cleared interior pool hits the residency guard
(server-context.cpp:3116) and silently falls back to full reprocess β€”
correct but slow. A **grace TTL** delays reclaim after the last descendant
detaches (cheap re-attach for bursty fan-out).
- **seq-id budget.** Every pool *and* every leaf consumes one `n_seq_max` slot.
A deep/wide tree spends seq-ids on interior nodes too; the boot-time reserve
accounts for both. Exposed in `/capacity`.
### 3.5 Materialization β€” the slot/seq decoupling (P2's real work)
Today a "pool" is just an ordinary server **slot** that happened to serve a
request: `shared_pool_slot` names a slot index, the pool's tokens live in
`slots[pool].prompt.tokens`, and the pool seq **is** the slot's seq. First-class
pools break that coupling, and this is the largest single implementation lift
in the milestone:
- **seq-ids beyond slots.** Pool seq-ids come from a boot-time reserve above
the slot range: `n_seq_max = n_parallel + --polykv-max-pools` (default
proposed: 16). A pool occupies a seq-id but **no slot** once materialized.
- **Prefill-only internal task.** `create`/`fork` materializes `[D, L)` via an
internal task that runs through the normal batching path (so it composes
with every KV feature) but emits **no logits and no generation**; it
transiently borrows a slot for the prefill, then the KV stays on the pool's
seq and the slot is returned. `seq_cp(parent β†’ pool, 0, D)` runs first for
fork/extend.
- **Host-side pool state.** The Pool object (tokens, cumulative hash, tree
links, pin/TTL) lives in server memory beside the slot table β€” *not* in a
slot β€” and is what `/pools` reports and attach validates against.
- **Cache layout (REVISED c6, bug-2248).** Originally pools required
`--kv-unified` (the split-stream `seq_cp` overload hard-asserts
`is_full`, so a partial cross-stream share was undefined β€” this was a
boot-time refusal). Since patch `0145` pools work on BOTH layouts:
unified shares stay zero-copy set-membership; on the split cache
(`--parallel` without `--kv-unified` β€” the layout the rolling-KV
position window requires) every prefix share runs as a PHYSICAL copy
via `oc_seq_share_prefix()` (full-stream `seq_cp` + trim to `[0,P)`,
positions preserved; cross-stream copies include the window's host
tail). Trade-off: no VRAM dedup off unified β€” each sharer stream
holds its own prefix copy (window mode caps device use at
`window_cells`/stream). The boot check is now an INFO stating the
copy-share semantics. `/capacity` is STREAM-AWARE on the split cache
(patch `0146`): the verdict checks the per-stream budget and free
streams ("no free stream (split cache)" hard-stop; headroom clamped
to free streams) and the payload adds `kv_streams_total` /
`kv_streams_free` / `kv_cells_per_stream` (null on unified).
---
## 4. API surface
All additive. Reuses the `opencoti` JSON namespace (`get_opencoti_props()`
:3787) and the `/slots` accumulator precedent (:183). Endpoint shapes follow
research Β§6.
### 4.1 Pool lifecycle (imperative, explicit)
| Verb | Endpoint | Body β†’ result |
|------|----------|---------------|
| create root | `POST /polykv/pools` | `{tokens? \| from_session?}` β†’ `{pool_id, prefix_len}`. `from_session` snapshots a live session's current context as the pool prefix β€” the **primary** creation path for agents (no token round-trip). |
| fork (extend or COW branch) | `POST /polykv/pools/{id}/fork` | `{branch_pos D, tokens[D:L] \| from_session?}` β†’ new child `{pool_id, parent:id, branch_pos, prefix_len}`. `D == parent.L` β‡’ extend (nesting); `D < parent.L` β‡’ COW branch. |
| attach | *(request path, unchanged mechanism)* | per-request `pool_id` (legacy `shared_pool_slot` kept). **`shared_prefix_n_tokens` becomes optional**: the server auto-computes `P` as the longest hash-match between the request prompt and the pool's stored tokens (**auto-P**) β€” killing the mis-set-P footgun and the HF-question confusion. An explicit `P` is still accepted and validated. |
| pin/evict/flush | `POST /polykv/pools/{id}/{pin\|evict\|flush}` | retention control (research Β§6 Req 1) |
| release | `DELETE /polykv/pools/{id}` | detach + reclaim when refcount hits 0 (respects grace TTL) |
Contiguous-prefix contract is checked at `fork`: the child's declared
`tokens[0:D]` must hash-match the parent's `[0:D)` (cumulative `prefix_hash`
at `D`), else `409 Conflict` β€” the caller then either re-roots or
full-prefills. All routes live under a `/polykv/` prefix so a future upstream
llamafile/llama.cpp bump can never collide with an unprefixed `/pools`.
### 4.2 Status (research Β§6 Req 1)
- `GET /polykv/pools` β†’ `{pools:[{pool_id, parent, branch_pos, prefix_len,
own_kv_bytes, subtree_kv_bytes, residency, pinned, last_access_ts,
children:[pool_id], sessions:[{session_id, suffix_n_tokens,
marginal_kv_bytes, tps_ewma, ttft_ms, state}]}], tree_depth, seq_ids_used,
seq_ids_max}`. The `parent`/`children` fields make the **tree** explicit.
**KV attribution is by ownership, never by membership**: a shared cell
belongs to many seqs, so summing per-seq bytes would double-count β€” a pool
reports `own_kv_bytes` = its `[D, L)` range only, plus a `subtree_kv_bytes`
rollup (own + all descendants + attached sessions' suffixes). Ξ£ own over the
tree == physical bytes, by construction.
- `GET /polykv/pools/{id}` β†’ one subtree.
- `/slots` gains `pool_id` + `tps_ewma` per slot (extends #677).
- `/props` `opencoti` block gains a `polykv` sub-object (config: floor,
admission mode, pin/TTL policy).
### 4.3 tps (research Β§6 Req 2)
- **Per-response** (already partly present): `timings{predicted_per_second,
prompt_per_second, cache_n}`.
- **Headers** (TGI model): `x-session-tps`, `x-cached-prefix-tokens`,
`x-pool-id`. **Deferred to P2+**: HTTP headers must be emitted before the
body, but the slot (and thus the live tps value) is assigned only after the
task is queued β€” a header set at request time would always be stale, and
trailers aren't portable. The per-response `timings` JSON already carries the
authoritative per-response rate; headers add value only once pool attach
happens at request routing (P2), where `x-pool-id`/`x-cached-prefix-tokens`
are known up front.
- **Live stream:** `GET /polykv/tps` (SSE) β€” periodic
`{ts, aggregate_tps, sessions:[{session_id, pool_id, tps_ewma}], mean_active_tps}`.
This is the signal the admission loop subscribes to.
- **`/metrics`** (Prometheus, low-cardinality per research Β§4): gauges
`opencoti_polykv:gen_throughput_tps{pool}`, `pool_kv_bytes{pool}`,
`pool_sessions{pool}`; counters `prefix_cache_hits_total{pool}`,
`queries_total{pool}`. Label = pool only (never session β€” cardinality).
### 4.4 Admission (research Β§6 Req 3; decision: both modes)
- `POST /polykv/pools/{id}/admission` `{target_tps_per_session, mode:"advisory"|
"enforced", on_saturation:"reject"|"warn"|"queue", guarantee_min_sessions,
settle_tokens, settle_max_ms}` β€” per-pool policy (last three = P7 Β§14,
defaults 1 / 48 / 5000).
- `GET /polykv/pools/{id}/capacity` β†’
`{can_admit, projected_mean_tps_if_admitted, headroom_sessions, kv_headroom_pct, reason}`
+ P7 (Β§14): `{settling, settle_remaining_ms, n_warming, n_pool_sessions,
guaranteed, projected_mean_tps_model, projected_mean_tps_measured,
drop_per_admit_ewma, known_session}`. The projection is measured-drop
based once the pool has absorbed β‰₯1 admit; the raw mean excludes
warming (EWMA-cold) sessions.
`headroom_sessions` is computed against **marginal** per-session KV
(`S_iΒ·bpt`), not full context β€” the basis of the ~7Γ— reduction (research Β§5).
- **Advisory (default):** endpoints report; the orchestrator decides. Nothing
is rejected server-side.
- **Enforced (opt-in):** on a new attach with projected `mean_active_tps <
floor` OR `kv_headroom` exhausted β†’ `reject` = `429`/`503` + `Retry-After`
(RFC 6585/9110); `warn` = admit + `X-Sessions-Remaining` header; `queue` =
hold until headroom returns. P7 (Β§14): the gate applies to **NEW sessions
only** (an existing session→slot affinity = continuation, never gated);
`"overcommit": true` in the request body bypasses it explicitly; and
`reason:"measurement settling"` maps to a bounded HOLD (≀ `settle_max_ms`
+ 1 s) rather than a 429.
- **Throttle-toward-floor is explicitly NOT v1.** Andes' insight (over-floor
generation is wasted GPU β†’ throttle fast sessions back toward the floor to
reclaim capacity) requires per-slot rate control inside the continuous-
batching decode loop β€” i.e. skipping a slot in some decode batches. That is
scheduler surgery with its own correctness/perf gates, not an API feature;
it is banked as a P4-follow candidate, and v1 `warn` is headers-only.
---
## 5. tps instrumentation (all-new, C++ server-side)
Upstream has no per-session running tps. Design:
- Extend the `/slots` lifetime accumulators (server-context.cpp:183) with a
per-slot decode-token counter + wall-clock, updated at each decode-batch
completion for the tokens attributable to that slot.
- **Experienced tps** per session = EWMA over a rolling window (default: last
`W` decoded tokens or `T` ms, whichever first) of that slot's decode
throughput. This is the quantity that *falls as concurrency rises* even while
aggregate climbs (attention doesn't batch-amortize β€” research Β§5), so it is
the correct SLO signal.
- **Aggregate** = system decode throughput (all slots' decode tokens / wall).
- **`mean_active_tps`** = mean of experienced tps across currently-decoding
sessions β€” the value the floor compares against (matches "average sessions
gen tps").
- Prefill tokens are excluded from "gen tps" (counted separately as
`prompt_per_second`).
- Cost: a handful of integer counters + one EWMA update per slot per batch β€”
negligible; gated behind the PolyKV block so off-path is byte-identical.
---
## 6. Admission control
Two coupled signals (research Β§5):
1. **Soft / low-lag:** `mean_active_tps` vs the floor. Directly measures the
SLO; leads `num_requests_waiting` (which lags).
2. **Hard wall:** pool KV-occupancy %. Reject *before* this forces
preempt-oldest eviction.
`projected_mean_tps_if_admitted` estimates the post-admit floor from the
`B_sat` knee model (below the knee admitting is ~free; above it every admit
costs everyone's TPOT ~linearly). On high-VRAM cards (RTX 6000 96 GB) the tps
knee is hit **before** the KV wall, so signal (1) dominates on opencoti's
hardware β€” which is exactly why the floor, not occupancy, is the headline.
Framing: "5 tok/s/session" = **TPOT SLO 200 ms/token; maximize goodput s.t.
TPOT β‰₯ floor** (a floor, not a maximize β€” Andes). The default is advisory: the
LangGraph capacity-gate node (Β§8) reads `/capacity` and decides. Enforced mode
is the same math with server-side `reject`/`warn`/`queue`.
---
## 7. Nesting & COW-fork β€” worked journeys
**Journey A β€” sub-agent parallelization (nesting / extend).** Main agent M
runs on root pool `P0 = [system+tools]` (len N0, FROZEN). M builds task context
and `POST /pools/P0/fork {branch_pos:N0, tokens:[N0:N1]}` β†’ interior pool `P1 =
[system+tools+task]`. M spawns k sub-agents that each attach to `P1` and decode
their own suffix in parallel. Physically, `P1`'s `[0:N0)` cells are P0's cells
(now in seq-set {P0,P1,leaf…}); `[N0:N1)` is prefilled once. The k sub-agents
cost only their marginal suffixes. The fan-out work that used to run
sequentially now runs concurrently under one shared branch.
**Journey B β€” divergent toolset (COW fork).** Two sub-agent groups share the
system preamble `[0:D)` but need different toolsets. Group B does
`POST /pools/P0/fork {branch_pos:D, tokens:[D:D']}` (D < N0) β†’ `P1' =
[system+toolsetB]`, sharing `[0:D)` zero-copy, prefilling `[D:D')` fresh. Its
sub-agents attach to `P1'`. No physical copy of the shared preamble.
**Invariants enforced.** (a) contiguous-prefix hash check at `fork`; (b)
immutability from birth β€” a pool's prefix is fully materialized before any
descendant can attach (Β§3.3), so no attach ever races a prefix change; (c)
recursive pin β€” interior pools stay resident while descendants attached; (d)
seq-id budget counts interior nodes. Divergence that is *not* a clean prefix of any
existing pool β†’ new root (or full prefill) β€” reported, never silently wrong.
**Hybrid/recurrent guard (bug-2203) is recursive.** Every attach/fork on a
recurrent or hybrid model still hits the exact-full-state guard
(server-context.cpp:3116) at every level.
---
## 8. LangGraph integration (research Β§6 Req 4)
Package home: a new **top-level `opencoti/` non-bun root** (per the repo's
"new non-bun roots" rule) β€” Python package `opencoti-langgraph` (pip). Kept out
of the bun workspace. Pin `BaseChatModel` signatures to a specific
`langchain-core` version tag (the API is mid-migration β€” research caveat).
- **`OpencotiChatModel(BaseChatModel)`** β€” implement `_generate` + `_llm_type`
(required), `_stream`/`_agenerate`/`_astream`; override `bind_tools`.
Populate `usage_metadata` and
`response_metadata{pool_id, session_tps, backpressure}` from the headers/Β§4.3.
- **Pool-control tools** β€” `create_pool`, `fork_pool`, `pool_status`,
`session_tps`, `capacity` exposed as LangChain tools via `bind_tools` /
`ToolNode`, so an agent can manage its own pool tree.
- **Capacity-gate node** β€” reads `/capacity`; returns
`Command(update={"capacity_ok":ok, "headroom_sessions":n}, goto=...)` to
route the graph (spawn vs. hold). `interrupt()` for block-until-free.
`InMemoryRateLimiter` is explicitly insufficient β€” it can't see per-session
tps or KV headroom.
- **Agent-state routing** β€” a reducer surfaces `headroom_sessions` /
`mean_active_tps` into graph state so conditional edges branch on live PolyKV
capacity.
---
## 9. Composition with the KV stack
PolyKV control is orthogonal to the KV *mechanics* and must not perturb them:
- **rolling-KV / DCA / turbo / TCQ / quant-KV** β€” pool sharing is `seq_cp`
membership on whatever cell type is configured; the tps/admission plane is
read-only over the existing decode loop. Off-path byte-identical (gate).
- **MTP / speculative** β€” per-session tps counts *accepted* tokens (the user-
visible generation rate), not draft steps.
- **`--parallel β‰₯ 2`** β€” the multi-slot requirement is already the
SharedKVPool baseline; the tree just adds interior seq-ids.
`--kv-unified` is optional since c6 (zero-copy shares with it,
copy-shares without it β€” see the layout note in Β§3.5).
- **hybrid/recurrent** β€” bug-2203 guard, recursive (Β§7).
## 10. Phased implementation plan (gated)
Full surface designed here; implementation lands in gated phases, each with a
correctness + off-path-identity gate before the next:
- **P1 β€” tps instrumentation** (Β§5). Per-slot counters + EWMA + `/slots`/`/props`
exposure + `/polykv/tps` SSE + `/metrics`. Prereq for everything.
**βœ… IMPLEMENTED 2026-07-16** (live in vendored tree, patch capture pending):
gates green β€” sustained EWMA βˆ’3.2% vs `timings` ground truth; 2-session
concurrency shows correct `aggregate_tps` β‰ˆ Ξ£ sessions + `mean_active_tps`;
`/metrics` 3 series render. bug-2205: aggregate MUST come from
Ξ£ `n_decoded_cum` (per-token), not `n_tokens_predicted_total`
(completion-time only).
- **P2 β€” pool objects + status API** (Β§3, Β§4.1-4.2). The slot/seq decoupling
(Β§3.5): seq-id reserve, prefill-only materialization task, host-side Pool
state, **auto-P attach**; `GET /polykv/pools`. Root pools only.
**βœ… IMPLEMENTED 2026-07-16** (live in vendored tree, patch capture pending):
gate 23/23 on Qwen3-4B β€” boot check (`--polykv-max-pools` without
`--kv-unified` fails clean; NOTE `--parallel` must be explicit, unset
auto-sets `kv_unified=true`), create-from-prompt prefill-only P=95,
auto-P exact attach `cache_n=95`, divergence attach `cache_n=42<95`,
`from_session` zero-copy `seq_cp` snapshot (affinity is evict-on-reuse β€”
only the most-recent session per slot resolves), pin/unpin/GET-one,
release + released-id clean fallback, disabled path inert, pooled vs
unpooled greedy text IDENTICAL (bit-shared cells). Mutations only on the
server thread via `SERVER_TASK_TYPE_POLYKV`; recurrent guard per bug-2203.
- **P3 β€” nesting & COW-fork** (Β§7). `fork` with arbitrary `D` (extend + COW
branch), cumulative-hash contract check, recursive pin, seq-id accounting.
**βœ… IMPLEMENTED 2026-07-16** (live in vendored tree, patch capture pending):
gate 18/18 substantive on Qwen3-4B β€” extend fork (D=parent.L, own=22),
COW branch (D=47<95, zero-copy `seq_cp` of `[0,D)` + fresh prefill),
contract violation β†’ clean 4xx, auto-P attach to a child (cache_n=117),
tree reporting (parent/children/branch_pos/own_len/tree_depth), release
ordering enforced (parent-with-children refused, leaves-first drains),
seq exhaustion at max_pools, `from_session` fork. The Β§4.1 prefix
contract is enforced **token-exact** (`std::equal` against the parent's
stored tokens β€” strictly subsumes the cumulative-hash check; `prefix_hash`
kept as identity fingerprint). Pooled-vs-unpooled greedy divergence on the
branch was classified benign by first-token probe (same top-5 ranking,
max |Ξ”logprob| 0.112 β€” batch-shape numerics, #495 class). Recurrent
bug-2203 guard is recursive: hybrid/recurrent forks must extend at
D == parent.L.
- **P4 β€” admission** (Β§6). `/capacity`, advisory metrics, then enforced mode
(`reject`/`warn`/`queue`).
**βœ… IMPLEMENTED 2026-07-16** (live in vendored tree, patch capture pending;
`queue` deferred per Β§11 default): gate 18/18 on Qwen3-4B β€” /capacity shape
(idle: can_admit, projected null, kv cells by OWNERSHIP: pools' own ranges +
slots' marginal suffixes via new `slot.n_pool_shared`), advisory floor never
rejects, **enforced+reject β†’ 429 + Retry-After** under an active decoding
session (tps-floor arm needs n_active β‰₯ 1), **enforced+warn β†’ 200 +
X-Sessions-Remaining**, off-path (no pool_id / unknown pool_id) untouched.
Projection = conservative fully-saturated knee: `mean_active Β· n/(n+1)`
(never over-admits below the real B_sat knee). Fast path: an atomic
enforced-pool count β€” ordinary traffic pays zero; the gate itself is a
high-priority OP_CAPACITY control task from `handle_completions_impl`
(same pattern as /slots' METRICS task).
- **P5 β€” LangGraph package** (Β§8). `opencoti-langgraph` under top-level
`opencoti/`. Runs in the first-class **`opencoti` conda env** (py3.11,
langchain-core 1.4.x + langgraph 1.2.x pinned; the same env will later
host the consultants council alongside opencoti-server). Includes the P6d
`CompactionNode` (Β§13).
**P5 βœ… IMPLEMENTED 2026-07-16**: offline 21/21 (pytest, httpx MockTransport
FakeServer), live gate **19/19** on Qwen3-4B `:8231 --parallel 2 --kv-unified
--polykv-max-pools 4` β€” pool tree (token-path fork D=65), `OpencotiChatModel`
invoke + streaming with pool attach proven (`cache_n=68 β‰₯ root prefix 65`),
`session_tps`/usage metadata, capacity context arm + gate-node routing,
enforced 429 β†’ `PoolSaturatedError(retry_after)` β†’ advisory recovery,
**P6c compact-by-refork e2e** (old pool released, migrated session coherent
on the new pool, `cache_n=71`), tps `ctx_*` fields. Contract lessons
(bug-2208): fork `prompt`/`tokens` = the child's **FULL prefix [0,L)**
token-exact vs the parent (never suffix-only β€” build via `/tokenize`:
parent tokens + suffix `add_special=false`); a pool prefix meant for
`/v1/chat/completions` attach must be the **chat-templated** system block
(raw text shares 0 tokens β†’ `cache_n=0`); Qwen3 thinking models need
`chat_template_kwargs: {enable_thinking: false}` for content-based gates.
Client accordingly ships `tokenize()` + `compact_by_refork(compacted_tokens=…)`.
- **P6 β€” context exhaustion & compaction** (Β§13). **P6a (ctx-shift pool
guard) + P6b (context visibility + /capacity context arm) land BEFORE P5**
per user decision 2026-07-16; P6c/P6d ship with P5.
**P6a+P6b βœ… IMPLEMENTED 2026-07-16** (live in vendored tree, patch capture
pending): gate 23/23 on Qwen3-4B at `-c 4096 --parallel 1 --context-shift`
(the exact Β§13.1-regime-3 configuration) β€” boot WARN; pooled session forced
past n_ctx = graceful `truncated=true` stop at 4021 tokens (position wall
fires one token before unified-cell exhaustion when the prefix is
pool-shared), NO shift, pool provably coherent afterwards (fresh attach
cache_n=60, needle answered); non-pooled ctx-shift still engages and
completes after pools released (off-path intact); `?expected_tokens` context
arm rejects with `"context headroom exhausted"`; `compaction_pressure`
0 at idle, ∈[0,1]; `orphaned_pin` true for a pinned never-attached leaf;
`/polykv/tps` sessions carry `ctx_used/ctx_total/ctx_headroom_tokens`.
Guard bookkeeping: `slot.n_pool_shared` is clamped to the retained prefix at
prompt-cache decision time (`min(n_pool_shared, n_past)`) β€” NOT blind-reset,
because cache reuse keeps pool-shared cells across later non-pool requests
on the same session. NOTE the geometry lesson: with `--kv-unified` the slot
n_ctx is the FULL `-c` (not divided by `--parallel`), and multi-slot cell
exhaustion precedes the positional wall β€” gates must use `--parallel 1`.
## 11. Open sub-decisions (defaulted; flag to change)
- EWMA window `W`/`T` defaults (proposed: 128 tokens or 2 s).
- Grace TTL for interior-pool reclaim (proposed: reuse `--slot-shrink-idle-ms`).
- `/metrics` opt-in flag name + default-off.
- Whether `queue` (admission) is v1 or P4-follow (proposed: `reject`+`warn` v1,
`queue` follow).
- `--polykv-max-pools` default (proposed: 16; each reserved seq-id costs KV
bookkeeping but no cells until materialized).
- Hash algorithm for the cumulative `prefix_hash` (proposed: xxhash64 rolling;
8 B/token host RAM β€” ~800 KB for a 100k-token prefix, negligible).
- SSE tps-stream cadence (proposed: 500 ms, configurable; stream is opt-in so
off-path cost is zero).
## 12. Verification
- Off-path identity: PolyKV-control-unused boot is byte-identical (DSO + decode).
- tps accuracy: instrumented per-session tps vs. an external wall-clock
token-rate measurement, Β±few %.
- Fork correctness: a branched pool's decode is logit-equivalent to the same
session full-prefilled (real_frac=0 / KLD), never greedy-needle.
- Tree lifetime: ancestor cells survive descendant-only references; reclaim on
refcount 0; recursive pin prevents the residency-miss fallback.
- Admission: projected vs. realized `mean_active_tps` under a spawn ramp;
`reject`/`warn` behavior + `Retry-After`/`X-Sessions-Remaining` headers.
- Composition gates: rolling-KV / DCA / turbo / MTP / `--parallel 2` unchanged.
## 13. Context exhaustion & compaction orchestration (P6)
*Added 2026-07-16 on user review: the design above admits sessions by tps floor
and marginal KV, but had no story for what happens when the shared context
actually fills β€” and continuous agentic frameworks WILL fill it. Decision:
P6a+P6b land BEFORE P5 (the LangGraph capacity gate builds on the context arm);
P6c/P6d ship with/after P5.*
### 13.1 What the engine does today (verified, three regimes)
1. **Prompt too long at admission** β†’ clean `ERROR_TYPE_EXCEED_CONTEXT_SIZE`.
2. **Generation reaches `n_ctx`, `--ctx-shift` off (default)** β†’ clean stop
with `truncated=true` (`server-context.cpp:1898`). Not corrupting, but
silent mid-thought truncation is exactly the "fails badly" mode for agents:
no advance warning, no orchestration signal.
3. **`--ctx-shift` on + pool-attached session β†’ CORRUPTION.** The shift does
`seq_rm` + `seq_add(…, -n_discard)` (`server-context.cpp:3261-3262`); in
the unified cache `seq_add` mutates the **per-cell position**, and
pool-shared cells are members of many seqs β€” the shift re-RoPEs the pool's
prefix for every other session and every descendant pool. Upstream guards
this for parent/child shared prompts (`server-context.cpp:3192`) but a
pool attach is neither, so the guard does not fire. The rest-kv-eviction
variant uses the same primitives and has the same hazard.
The "safe" naive alternative β€” COW-copy shared cells before shifting β€”
allocates a duplicate of the prefix at the exact moment the cache is full.
Both intuited failure modes (corruption / usage inflation) are real.
### 13.2 Design principle: compaction is a prompt REWRITE, never an in-place KV op
LangGraph (and every agentic framework) compacts at the prompt-assembly
level: `trim_messages`, `RemoveMessage` reducers, or a summarization
`pre_model_hook` that replaces old messages with a running summary. The
compacted context is **new text** β€” its tokens cannot match the old cells, so
cell-level "compaction" is not even meaningful. Therefore the server must
never mutate pooled cells; the correct primitive already exists in the tree:
> **Compaction = re-root via fork.** The system prompt + tool definitions
> survive compaction verbatim β€” that is the stable ancestor pool
> `[0, D_sys)`. Compacted context = `fork(ancestor, D=D_sys,
> tokens = summary + recent tail)` β€” a new sibling branch, prefilled once.
> Sessions migrate to the new pool; the old working subtree is `release`d.
> Pool immutability is preserved by construction, no cell position is ever
> touched, and net cells go **down** (old subtree reclaimed) instead of up
> (COW duplication).
Pinned pools under this model are a **leak hazard, not a corruption hazard**:
a pin left on the abandoned subtree blocks reclaim. Discipline is
unpin-after-migrate; the server reports orphans (pinned + zero sessions +
zero children) so the orchestrator can sweep.
### 13.3 Phases
- **P6a β€” corruption guard (before P5).** Extend the ctx-shift refusal to
pool-attached slots (`slot.n_pool_shared > 0` β‡’ clean error, same as the
`:3192` shared-prompt refusal) + boot WARN when `--ctx-shift` is combined
with `--polykv-max-pools`. Rest-kv-eviction path included. Off-path inert.
- **P6b β€” visibility + spawn-gate parity (before P5).** Per-session
`ctx_used`/`ctx_headroom_tokens` in `/polykv/tps` + `/slots`; `/capacity`
gains `?expected_tokens=N` and a **context arm** to `can_admit`
(reason `"context headroom exhausted"`) so sub-agent spawns are gated on
context exactly like the tps floor; plus a `compaction_pressure` field
(0..1, driven by free-cell fraction and largest-session share) so the
orchestrator compacts BEFORE the wall.
- **P6c β€” compact-by-refork protocol (with P5).** Documented two-step:
summarize via normal completion β†’ `fork` the stable ancestor at `D_sys`
with the compacted suffix β†’ migrate sessions β†’ `release` old subtree.
Orphaned-pin flag in `/polykv/pools`. Atomic convenience endpoint only if
the two-step proves racy in practice.
- **P6d β€” LangGraph side (inside opencoti-langgraph).** `CompactionNode`
(pre_model_hook pattern: watch `response_metadata` headroom /
`compaction_pressure` β†’ summarize β†’ re-root β†’ swap `pool_id` in graph
state); the capacity-gate node checks the context arm from day 1.
### 13.4 Non-goals
- In-place KV compaction/merging of pooled cells (meaningless under rewrite
semantics; corrupting under sharing).
- Server-side self-summarization (the orchestrator owns the summary β€” it has
the conversation semantics; the server only has tokens).
- Growing `n_ctx` at runtime (allocation is boot-time; capacity planning is
the admission plane's job).
---
## 14. P7 β€” measurement-settled admission, measured-drop forecast, guaranteed minimum
> **Status: design locked (2026-07-23, user).** Driven by the
> `courier-qwopus9b-mtp-s55-p10` run: floor 15 tps, spawns every 5 s on a
> tps EWMA (tau 2 s) that had not yet absorbed the previous admit
> (38β†’31.7β†’21.7β†’15.9 at successive ticks), overshoot to n=6 at 11.6 tps,
> and the corrective shrink actuated **22 minutes later** (workers retire
> only at episode end). Decisions: pacing lives in BOTH layers (server-side
> hold + orchestrator pacing); overflow valve = per-pool
> `guarantee_min_sessions` (default 1) + per-request `overcommit` honored.
### 14.1 Root causes (from the run + code audit)
1. **Admit-on-stale-measurement.** Nothing marks a pool "still absorbing the
last admit"; every 5 s tick admitted again before the EWMA settled.
2. **Warming bias.** `mean_active_tps` averages `tps_ewma` over all
processing slots, including just-admitted ones still at `tps_ewma == 0`
β€” right after a spawn the mean is dragged toward 0, corrupting both the
floor comparison and the projection.
3. **Optimistic projection.** `n/(n+1)` (flat-aggregate knee model) under-
predicted the real drop at every step of the run (e.g. predicted 17.4
post-admit, reality 15.9). No measured feedback.
4. **Courier bypassed the projection.** `scheduler()` compares raw
`mean_active_tps` to the floor instead of
`projected_mean_tps_if_admitted` (bug β€” fixed in P7's courier pass).
5. **Enforced gate cannot tell admission from continuation.** The gate at
request routing fires on ANY request carrying `pool_id`, so a pool under
floor 429s its own running sessions' next turns β€” livelock risk. The gate
must gate only NEW sessions (no `session_to_slot` affinity yet).
6. **Shrink actuation lag is structural** (episode-end retire). P7 does not
change it; it makes overshoot rare instead (prevention, not cure).
### 14.2 Server design
**Settle window (per pool).** On each NEW-session admit record
`{t_admit_us, admit_slot, n_decoded_at_admit, mean_at_admit}`. The pool is
**settling** until the admitted slot has decoded `settle_tokens` (default 48)
more tokens OR `settle_max_ms` (default 5000) elapsed. While settling:
- `/capacity` β†’ `settling: true`, `settle_remaining_ms`, `can_admit: false`,
`reason: "measurement settling"` (advisory clients wait, not shrink).
- Enforced mode β†’ the gate HOLDS the attach (bounded sleep-poll ≀
`settle_max_ms`, then re-evaluates) instead of 429 β€” "slow the spawn just
enough", never a hard reject for settling alone.
**Warming-aware mean.** Slots with `tps_ewma == 0` while processing are
**warming**: excluded from `mean_active_tps`/projection; reported as
`n_warming` in `/capacity` and `/polykv/tps`.
**Measured-drop forecast.** When a settle completes, fold
`drop = max(0, mean_at_admit βˆ’ mean_settled_now)` into a per-pool EWMA
(`drop_ewma`, alpha 0.3). Projection:
`projected_measured = mean_settled βˆ’ drop_ewma` (once β‰₯1 sample),
`projected_model = meanΒ·n/(n+1)` (fallback / always reported). The floor arm
uses `projected_measured` when available, else the model.
`/capacity` reports both plus `drop_per_admit_ewma`.
**Idle-gap estimate.** When every session is between turns (tool calls in
flight) `n_active` is 0 and "no projection" reads as free capacity β€” the
hole that still ramped the first P7 gate run to n=8. If the pool has live
sessions and a FRESH last settled mean (< 30 s), `/capacity` projects from
it instead (`projected_idle_estimate: true`; measured-drop variant when
available, else `last_settled_meanΒ·n_pool/(n_pool+1)`), and the floor arm
applies (`reason: "projected mean tps below floor (idle estimate)"`). A
long-idle pool (stale sample) genuinely has capacity and stays
unprojected.
**Guaranteed minimum (the anti-deadlock valve).** Admission policy gains
`guarantee_min_sessions` (default **1**, settable via
`POST /polykv/pools/{id}/admission`). In `/capacity`, when the pool's
currently-attached session count `< guarantee_min_sessions`, `can_admit` is
**true** with `reason:"guaranteed minimum"`, `guaranteed:true` β€” bypassing
the tps-floor and kv-headroom arms. The **context arm stays hard**
(`expected_tokens > free_cells` still refuses β€” cells are physical). A brand
-new or freshly forked (nested) pool therefore ALWAYS gets its first agent,
even oversubscribed past the target; deadlock is impossible by construction.
Once at/over the minimum, replacements follow the normal floor/target checks
β€” an oversubscribed pool converges back down as episodes end.
**Per-request `overcommit`.** `"overcommit": true` in the completion body
(threaded like `pool_id`) skips the enforced gate for that request β€”
explicit caller-controlled oversubscription, logged.
**Continuation bypass.** The enforced gate resolves the request's
`session_id` against `session_to_slot` (via the capacity control task, which
runs on the server thread); a known session is a continuation β†’ never gated.
### 14.3 Courier/orchestrator pass (both-layer pacing)
- `scheduler()` gates spawns on `projected_mean_tps_if_admitted` (not raw
mean) AND `settling == false`; a settling tick is a no-op (never a shrink
signal). `n_warming > 0` also defers judgment.
- After `spawn_worker`, the scheduler does not evaluate again until the
server reports the pool settled (one poll of `/capacity` suffices β€” the
server owns the settle clock).
- Report gains settle/guarantee/overcommit event rows + a settling band on
the tps chart.
### 14.4 Compatibility & phases
All fields additive; pools without a policy behave exactly as before except
the warming exclusion (a pure measurement fix) and the continuation bypass
(strictly less rejection). Off-path (no PolyKV) byte-identical.
- **P7a** β€” warming-aware mean + settle state + measured-drop forecast +
`/capacity` fields (server). **DONE 2026-07-23.**
- **P7b** β€” `guarantee_min_sessions` + `overcommit` + continuation bypass +
settling hold in the enforced gate (server). **DONE 2026-07-23.**
- **P7c** β€” courier scheduler/report pass + rerun the courier gate.
**PASS 2026-07-23** (solidPC 3090, Qwopus3.5-9B MTP, 10 pkg Γ— 55 steps,
floor 15): vs the baseline run, wall 2187β†’1919 s (βˆ’12%), time-under-floor
1648β†’790 s (βˆ’52%), deep sub-floor (<7.5 tps) 706β†’122 s (βˆ’83%), delivery
p50 1350β†’764 s (βˆ’43%), same 100% score. Two gate iterations: the first
(settle+guarantee+projection only) got βˆ’23% under-floor but still ramped
to n=8 through idle-gap "no projection = free capacity" spawns; the
idle-estimate arm closed that hole.