ManniX-ITA commited on
Commit
4b5f5f1
Β·
verified Β·
1 Parent(s): 5d55dc3

c4: polykv_api doc

Browse files
Files changed (1) hide show
  1. docs/features/polykv_api.md +573 -0
docs/features/polykv_api.md ADDED
@@ -0,0 +1,573 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # PolyKV Control-Plane API β€” Design (F5 M6-S5 / #712)
2
+
3
+ **Status:** design (2026-07-16). Implementation is phased (Β§10); nothing here
4
+ is shipped yet.
5
+ **Inputs:** [polykv_api_research.md](polykv_api_research.md) (design-space
6
+ survey), [poly_kv.md](poly_kv.md) (the KV mechanism this sits on top of),
7
+ [llamafile-usage.md](../llamafile-usage.md) Β§3.4 (SharedKVPool user surface).
8
+ **Single rule:** additive / opt-in. Every endpoint and field lives under the
9
+ existing `opencoti` JSON namespace or new `/polykv/*` routes; no upstream
10
+ behavior changes when PolyKV control is unused.
11
+
12
+ ---
13
+
14
+ ## 1. Goals
15
+
16
+ A rich, opt-in control plane that satisfies every multi-agent serving journey
17
+ on top of the existing SharedKVPool (`shared_pool_slot` +
18
+ `shared_prefix_n_tokens`, server-context.cpp:3085). Four requirements, plus
19
+ two the requirements imply:
20
+
21
+ 1. **Full status** of pools + the sessions attached to each.
22
+ 2. **tps reporting** β€” aggregate system throughput *and* per-session
23
+ generation tps. (All-new: upstream llama.cpp reports per-*response*
24
+ `timings` but has no per-session/slot running instrumentation.)
25
+ 3. **Autonomous capacity/admission loop** β€” a main session sets a per-session
26
+ gen-tps target (e.g. 5 tps); when the average across active sessions
27
+ approaches the floor the orchestrator holds off spawning sub-agents.
28
+ Behavioral knobs: reject a new session vs. warn.
29
+ 4. **LangGraph extensions** β€” a custom `BaseChatModel`, the pool-control API
30
+ exposed as LangGraph tools, and PolyKV-aware agent-state routing.
31
+ 5. **Nesting** (implied by 3): a sub-agent that discovers fan-out work can
32
+ become a pool parent for its own sub-agents, so parallelizable tasks run
33
+ in parallel instead of being forced sequential.
34
+ 6. **Fork / branch** (COW): a sub-agent group that shares an *ancestor* prefix
35
+ but then diverges (different toolset/context) gets its own shared pool
36
+ branched at the divergence point.
37
+
38
+ ## 2. Decisions locked (2026-07-16)
39
+
40
+ | # | Decision | Choice |
41
+ |---|----------|--------|
42
+ | 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. |
43
+ | Admission | enforce the tps floor or advise | **Both** β€” advisory (report) by default; optional per-pool enforced mode with `reject`\|`warn`\|`queue`. |
44
+ | 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. |
45
+ | Scope | deliverable shape | **Design the full surface now; implement in gated phases** (Β§10). |
46
+ | Fork | divergent child prefix | **First-class in v1** (copy-free COW, Β§3.2 / Β§7) β€” *not* deferred. |
47
+
48
+ **Defaulted (override any):** transport = HTTP on the engine + SSE for the
49
+ live tps stream (poll fallback); tenancy = single trust domain (no per-pool
50
+ ACL); tps = per-session *experienced* decode tps (EWMA) + aggregate decode
51
+ throughput, floor = mean of active sessions' experienced tps (prefill
52
+ excluded); nesting depth uncapped; interior pools pinned while any descendant
53
+ is attached + grace TTL; seq-ids for pools+leaves sized at boot.
54
+
55
+ ---
56
+
57
+ ## 3. Data model
58
+
59
+ ### 3.1 A pool is a tree node
60
+
61
+ ```
62
+ Pool {
63
+ id : int32 // seq_id in the unified cache
64
+ parent : int32 | ROOT // -1 for a root pool
65
+ branch_pos D : int32 // shared-ancestor length inherited from parent
66
+ own_range : [D, L) // tokens this pool materialized itself
67
+ prefix_len L : int32 // total shared prefix = D + |own_range|
68
+ tokens : [llama_token] // full prefix token array [0, L), host-side (auto-P + fork validation)
69
+ prefix_hash : [u64] // cumulative rolling hash per position (validates any D; ~8 B/token host RAM)
70
+ pin : bool // resident-pinned (auto while descendants attached)
71
+ children : [Pool] // interior pools branched/extended from this one
72
+ sessions : [Session] // leaf sharers attached at this pool's prefix
73
+ last_access : ts
74
+ }
75
+ Session { // a leaf = an ordinary decoding slot
76
+ id : int32 // seq_id
77
+ pool : int32 // the pool it attached to
78
+ suffix_range : [L, L+S) // its own private, mutable continuation
79
+ tps_ewma : f32 // experienced decode tps
80
+ ttft_ms, state, ...
81
+ }
82
+ ```
83
+
84
+ The **root** pool (`parent = ROOT`, `D = 0`) is what today's
85
+ `shared_pool_slot` request already produces. Everything else is new.
86
+
87
+ ### 3.2 Three edge types, one mechanism
88
+
89
+ A child pool inherits `[0, D)` from its parent and owns `[D, L)`. `D` selects
90
+ the edge type:
91
+
92
+ - **extend** β€” `D = parent.L`. Child = parent prefix **+** appended shared
93
+ tokens. This is *nesting*: a sub-agent adds its own shared context and hands
94
+ it to its sub-agents.
95
+ - **branch (COW fork)** β€” `D < parent.L`. Child shares the common ancestor
96
+ `[0, D)` and diverges with its own `[D, L)`. This is the fork: same system
97
+ prefix, different toolset after `D`.
98
+ - **root** β€” `parent = ROOT`, `D = 0`.
99
+
100
+ **Why the fork is copy-free.** `seq_cp` in the unified cache is additive
101
+ membership: for each cell where `seq_has(cell, parent)` in `[0, D)` it calls
102
+ `seq_add(cell, child)` (llama-kv-cache.cpp:2007-2044). No cell is duplicated β€”
103
+ the ancestor cells simply gain the child in their sequence set. Because
104
+ prefixes are **immutable** (FROZEN, Β§3.3), the shared range is never mutated,
105
+ so the "copy" a classic COW would eventually pay never happens. The child only
106
+ computes new KV for `[D, L)`, prefilled at RoPE position `D` attending to the
107
+ shared `[0, D)` already in cache β€” exactly how attaching with a shorter
108
+ `shared_prefix_n_tokens` works today. **Extend and branch are the same code
109
+ path parameterized by `D`.**
110
+
111
+ ### 3.3 Immutability & versioning
112
+
113
+ **Pools are immutable from birth.** A pool's prefix is fully materialized at
114
+ `create`/`fork` and never changes afterwards β€” there is no OPEN/extend-in-place
115
+ state. "Extending" a pool *always* means creating a child (`fork` with
116
+ `D = parent.L`). This is simpler than a freeze-on-first-attach state machine
117
+ and loses nothing: the "build context incrementally" journey is covered by the
118
+ main agent decoding in its **own session** and then forking `from_session`
119
+ (Β§4.1), which snapshots its current context as a new pool in one call β€” no
120
+ in-place mutation ever needed.
121
+
122
+ This makes "add a tool to the shared prefix after agents attached"
123
+ well-defined β€” it is a *new versioned pool* (a fork), and the old one keeps
124
+ serving its sharers unchanged. (This is the precise answer to the HuggingFace
125
+ question: the prefix is static per pool; a tool/MCP added later is a new pool
126
+ version, valid only for sessions that attach to it β€” never mutated under
127
+ sessions already on the old one.)
128
+
129
+ ### 3.4 Lifetime
130
+
131
+ Cell freeing is implicit-refcounted: a cell is reclaimed only when its sequence
132
+ set empties, so an ancestor's cells physically survive as long as *any*
133
+ descendant references them β€” even if the ancestor's own slot is torn down. On
134
+ top of that:
135
+
136
+ - **Pin policy.** Interior pools are auto-pinned (`--no-clear-idle` semantics,
137
+ per-node) while any descendant is attached. Without this a new leaf attaching
138
+ to an idle-cleared interior pool hits the residency guard
139
+ (server-context.cpp:3116) and silently falls back to full reprocess β€”
140
+ correct but slow. A **grace TTL** delays reclaim after the last descendant
141
+ detaches (cheap re-attach for bursty fan-out).
142
+ - **seq-id budget.** Every pool *and* every leaf consumes one `n_seq_max` slot.
143
+ A deep/wide tree spends seq-ids on interior nodes too; the boot-time reserve
144
+ accounts for both. Exposed in `/capacity`.
145
+
146
+ ### 3.5 Materialization β€” the slot/seq decoupling (P2's real work)
147
+
148
+ Today a "pool" is just an ordinary server **slot** that happened to serve a
149
+ request: `shared_pool_slot` names a slot index, the pool's tokens live in
150
+ `slots[pool].prompt.tokens`, and the pool seq **is** the slot's seq. First-class
151
+ pools break that coupling, and this is the largest single implementation lift
152
+ in the milestone:
153
+
154
+ - **seq-ids beyond slots.** Pool seq-ids come from a boot-time reserve above
155
+ the slot range: `n_seq_max = n_parallel + --polykv-max-pools` (default
156
+ proposed: 16). A pool occupies a seq-id but **no slot** once materialized.
157
+ - **Prefill-only internal task.** `create`/`fork` materializes `[D, L)` via an
158
+ internal task that runs through the normal batching path (so it composes
159
+ with every KV feature) but emits **no logits and no generation**; it
160
+ transiently borrows a slot for the prefill, then the KV stays on the pool's
161
+ seq and the slot is returned. `seq_cp(parent β†’ pool, 0, D)` runs first for
162
+ fork/extend.
163
+ - **Host-side pool state.** The Pool object (tokens, cumulative hash, tree
164
+ links, pin/TTL) lives in server memory beside the slot table β€” *not* in a
165
+ slot β€” and is what `/pools` reports and attach validates against.
166
+ - **Unified KV required.** The split-stream `seq_cp` overload hard-asserts
167
+ `is_full` (llama-kv-cache.cpp:2063); pool sharing is only defined under
168
+ `--kv-unified` β€” already the SharedKVPool prerequisite, now stated as a
169
+ boot-time check when `--polykv-max-pools > 0`.
170
+
171
+ ---
172
+
173
+ ## 4. API surface
174
+
175
+ All additive. Reuses the `opencoti` JSON namespace (`get_opencoti_props()`
176
+ :3787) and the `/slots` accumulator precedent (:183). Endpoint shapes follow
177
+ research Β§6.
178
+
179
+ ### 4.1 Pool lifecycle (imperative, explicit)
180
+
181
+ | Verb | Endpoint | Body β†’ result |
182
+ |------|----------|---------------|
183
+ | 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). |
184
+ | 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. |
185
+ | 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. |
186
+ | pin/evict/flush | `POST /polykv/pools/{id}/{pin\|evict\|flush}` | retention control (research Β§6 Req 1) |
187
+ | release | `DELETE /polykv/pools/{id}` | detach + reclaim when refcount hits 0 (respects grace TTL) |
188
+
189
+ Contiguous-prefix contract is checked at `fork`: the child's declared
190
+ `tokens[0:D]` must hash-match the parent's `[0:D)` (cumulative `prefix_hash`
191
+ at `D`), else `409 Conflict` β€” the caller then either re-roots or
192
+ full-prefills. All routes live under a `/polykv/` prefix so a future upstream
193
+ llamafile/llama.cpp bump can never collide with an unprefixed `/pools`.
194
+
195
+ ### 4.2 Status (research Β§6 Req 1)
196
+
197
+ - `GET /polykv/pools` β†’ `{pools:[{pool_id, parent, branch_pos, prefix_len,
198
+ own_kv_bytes, subtree_kv_bytes, residency, pinned, last_access_ts,
199
+ children:[pool_id], sessions:[{session_id, suffix_n_tokens,
200
+ marginal_kv_bytes, tps_ewma, ttft_ms, state}]}], tree_depth, seq_ids_used,
201
+ seq_ids_max}`. The `parent`/`children` fields make the **tree** explicit.
202
+ **KV attribution is by ownership, never by membership**: a shared cell
203
+ belongs to many seqs, so summing per-seq bytes would double-count β€” a pool
204
+ reports `own_kv_bytes` = its `[D, L)` range only, plus a `subtree_kv_bytes`
205
+ rollup (own + all descendants + attached sessions' suffixes). Ξ£ own over the
206
+ tree == physical bytes, by construction.
207
+ - `GET /polykv/pools/{id}` β†’ one subtree.
208
+ - `/slots` gains `pool_id` + `tps_ewma` per slot (extends #677).
209
+ - `/props` `opencoti` block gains a `polykv` sub-object (config: floor,
210
+ admission mode, pin/TTL policy).
211
+
212
+ ### 4.3 tps (research Β§6 Req 2)
213
+
214
+ - **Per-response** (already partly present): `timings{predicted_per_second,
215
+ prompt_per_second, cache_n}`.
216
+ - **Headers** (TGI model): `x-session-tps`, `x-cached-prefix-tokens`,
217
+ `x-pool-id`. **Deferred to P2+**: HTTP headers must be emitted before the
218
+ body, but the slot (and thus the live tps value) is assigned only after the
219
+ task is queued β€” a header set at request time would always be stale, and
220
+ trailers aren't portable. The per-response `timings` JSON already carries the
221
+ authoritative per-response rate; headers add value only once pool attach
222
+ happens at request routing (P2), where `x-pool-id`/`x-cached-prefix-tokens`
223
+ are known up front.
224
+ - **Live stream:** `GET /polykv/tps` (SSE) β€” periodic
225
+ `{ts, aggregate_tps, sessions:[{session_id, pool_id, tps_ewma}], mean_active_tps}`.
226
+ This is the signal the admission loop subscribes to.
227
+ - **`/metrics`** (Prometheus, low-cardinality per research Β§4): gauges
228
+ `opencoti_polykv:gen_throughput_tps{pool}`, `pool_kv_bytes{pool}`,
229
+ `pool_sessions{pool}`; counters `prefix_cache_hits_total{pool}`,
230
+ `queries_total{pool}`. Label = pool only (never session β€” cardinality).
231
+
232
+ ### 4.4 Admission (research Β§6 Req 3; decision: both modes)
233
+
234
+ - `POST /polykv/pools/{id}/admission` `{target_tps_per_session, mode:"advisory"|
235
+ "enforced", on_saturation:"reject"|"warn"|"queue"}` β€” per-pool policy.
236
+ - `GET /polykv/pools/{id}/capacity` β†’
237
+ `{can_admit, projected_mean_tps_if_admitted, headroom_sessions, kv_headroom_pct, reason}`.
238
+ `headroom_sessions` is computed against **marginal** per-session KV
239
+ (`S_iΒ·bpt`), not full context β€” the basis of the ~7Γ— reduction (research Β§5).
240
+ - **Advisory (default):** endpoints report; the orchestrator decides. Nothing
241
+ is rejected server-side.
242
+ - **Enforced (opt-in):** on a new attach with projected `mean_active_tps <
243
+ floor` OR `kv_headroom` exhausted β†’ `reject` = `429`/`503` + `Retry-After`
244
+ (RFC 6585/9110); `warn` = admit + `X-Sessions-Remaining` header; `queue` =
245
+ hold until headroom returns.
246
+ - **Throttle-toward-floor is explicitly NOT v1.** Andes' insight (over-floor
247
+ generation is wasted GPU β†’ throttle fast sessions back toward the floor to
248
+ reclaim capacity) requires per-slot rate control inside the continuous-
249
+ batching decode loop β€” i.e. skipping a slot in some decode batches. That is
250
+ scheduler surgery with its own correctness/perf gates, not an API feature;
251
+ it is banked as a P4-follow candidate, and v1 `warn` is headers-only.
252
+
253
+ ---
254
+
255
+ ## 5. tps instrumentation (all-new, C++ server-side)
256
+
257
+ Upstream has no per-session running tps. Design:
258
+
259
+ - Extend the `/slots` lifetime accumulators (server-context.cpp:183) with a
260
+ per-slot decode-token counter + wall-clock, updated at each decode-batch
261
+ completion for the tokens attributable to that slot.
262
+ - **Experienced tps** per session = EWMA over a rolling window (default: last
263
+ `W` decoded tokens or `T` ms, whichever first) of that slot's decode
264
+ throughput. This is the quantity that *falls as concurrency rises* even while
265
+ aggregate climbs (attention doesn't batch-amortize β€” research Β§5), so it is
266
+ the correct SLO signal.
267
+ - **Aggregate** = system decode throughput (all slots' decode tokens / wall).
268
+ - **`mean_active_tps`** = mean of experienced tps across currently-decoding
269
+ sessions β€” the value the floor compares against (matches "average sessions
270
+ gen tps").
271
+ - Prefill tokens are excluded from "gen tps" (counted separately as
272
+ `prompt_per_second`).
273
+ - Cost: a handful of integer counters + one EWMA update per slot per batch β€”
274
+ negligible; gated behind the PolyKV block so off-path is byte-identical.
275
+
276
+ ---
277
+
278
+ ## 6. Admission control
279
+
280
+ Two coupled signals (research Β§5):
281
+
282
+ 1. **Soft / low-lag:** `mean_active_tps` vs the floor. Directly measures the
283
+ SLO; leads `num_requests_waiting` (which lags).
284
+ 2. **Hard wall:** pool KV-occupancy %. Reject *before* this forces
285
+ preempt-oldest eviction.
286
+
287
+ `projected_mean_tps_if_admitted` estimates the post-admit floor from the
288
+ `B_sat` knee model (below the knee admitting is ~free; above it every admit
289
+ costs everyone's TPOT ~linearly). On high-VRAM cards (RTX 6000 96 GB) the tps
290
+ knee is hit **before** the KV wall, so signal (1) dominates on opencoti's
291
+ hardware β€” which is exactly why the floor, not occupancy, is the headline.
292
+
293
+ Framing: "5 tok/s/session" = **TPOT SLO 200 ms/token; maximize goodput s.t.
294
+ TPOT β‰₯ floor** (a floor, not a maximize β€” Andes). The default is advisory: the
295
+ LangGraph capacity-gate node (Β§8) reads `/capacity` and decides. Enforced mode
296
+ is the same math with server-side `reject`/`warn`/`queue`.
297
+
298
+ ---
299
+
300
+ ## 7. Nesting & COW-fork β€” worked journeys
301
+
302
+ **Journey A β€” sub-agent parallelization (nesting / extend).** Main agent M
303
+ runs on root pool `P0 = [system+tools]` (len N0, FROZEN). M builds task context
304
+ and `POST /pools/P0/fork {branch_pos:N0, tokens:[N0:N1]}` β†’ interior pool `P1 =
305
+ [system+tools+task]`. M spawns k sub-agents that each attach to `P1` and decode
306
+ their own suffix in parallel. Physically, `P1`'s `[0:N0)` cells are P0's cells
307
+ (now in seq-set {P0,P1,leaf…}); `[N0:N1)` is prefilled once. The k sub-agents
308
+ cost only their marginal suffixes. The fan-out work that used to run
309
+ sequentially now runs concurrently under one shared branch.
310
+
311
+ **Journey B β€” divergent toolset (COW fork).** Two sub-agent groups share the
312
+ system preamble `[0:D)` but need different toolsets. Group B does
313
+ `POST /pools/P0/fork {branch_pos:D, tokens:[D:D']}` (D < N0) β†’ `P1' =
314
+ [system+toolsetB]`, sharing `[0:D)` zero-copy, prefilling `[D:D')` fresh. Its
315
+ sub-agents attach to `P1'`. No physical copy of the shared preamble.
316
+
317
+ **Invariants enforced.** (a) contiguous-prefix hash check at `fork`; (b)
318
+ immutability from birth β€” a pool's prefix is fully materialized before any
319
+ descendant can attach (Β§3.3), so no attach ever races a prefix change; (c)
320
+ recursive pin β€” interior pools stay resident while descendants attached; (d)
321
+ seq-id budget counts interior nodes. Divergence that is *not* a clean prefix of any
322
+ existing pool β†’ new root (or full prefill) β€” reported, never silently wrong.
323
+
324
+ **Hybrid/recurrent guard (bug-2203) is recursive.** Every attach/fork on a
325
+ recurrent or hybrid model still hits the exact-full-state guard
326
+ (server-context.cpp:3116) at every level.
327
+
328
+ ---
329
+
330
+ ## 8. LangGraph integration (research Β§6 Req 4)
331
+
332
+ Package home: a new **top-level `opencoti/` non-bun root** (per the repo's
333
+ "new non-bun roots" rule) β€” Python package `opencoti-langgraph` (pip). Kept out
334
+ of the bun workspace. Pin `BaseChatModel` signatures to a specific
335
+ `langchain-core` version tag (the API is mid-migration β€” research caveat).
336
+
337
+ - **`OpencotiChatModel(BaseChatModel)`** β€” implement `_generate` + `_llm_type`
338
+ (required), `_stream`/`_agenerate`/`_astream`; override `bind_tools`.
339
+ Populate `usage_metadata` and
340
+ `response_metadata{pool_id, session_tps, backpressure}` from the headers/Β§4.3.
341
+ - **Pool-control tools** β€” `create_pool`, `fork_pool`, `pool_status`,
342
+ `session_tps`, `capacity` exposed as LangChain tools via `bind_tools` /
343
+ `ToolNode`, so an agent can manage its own pool tree.
344
+ - **Capacity-gate node** β€” reads `/capacity`; returns
345
+ `Command(update={"capacity_ok":ok, "headroom_sessions":n}, goto=...)` to
346
+ route the graph (spawn vs. hold). `interrupt()` for block-until-free.
347
+ `InMemoryRateLimiter` is explicitly insufficient β€” it can't see per-session
348
+ tps or KV headroom.
349
+ - **Agent-state routing** β€” a reducer surfaces `headroom_sessions` /
350
+ `mean_active_tps` into graph state so conditional edges branch on live PolyKV
351
+ capacity.
352
+
353
+ ---
354
+
355
+ ## 9. Composition with the KV stack
356
+
357
+ PolyKV control is orthogonal to the KV *mechanics* and must not perturb them:
358
+
359
+ - **rolling-KV / DCA / turbo / TCQ / quant-KV** β€” pool sharing is `seq_cp`
360
+ membership on whatever cell type is configured; the tps/admission plane is
361
+ read-only over the existing decode loop. Off-path byte-identical (gate).
362
+ - **MTP / speculative** β€” per-session tps counts *accepted* tokens (the user-
363
+ visible generation rate), not draft steps.
364
+ - **`--parallel β‰₯ 2` / `--kv-unified`** β€” the multi-slot requirement is already
365
+ the SharedKVPool baseline; the tree just adds interior seq-ids.
366
+ - **hybrid/recurrent** β€” bug-2203 guard, recursive (Β§7).
367
+
368
+ ## 10. Phased implementation plan (gated)
369
+
370
+ Full surface designed here; implementation lands in gated phases, each with a
371
+ correctness + off-path-identity gate before the next:
372
+
373
+ - **P1 β€” tps instrumentation** (Β§5). Per-slot counters + EWMA + `/slots`/`/props`
374
+ exposure + `/polykv/tps` SSE + `/metrics`. Prereq for everything.
375
+ **βœ… IMPLEMENTED 2026-07-16** (live in vendored tree, patch capture pending):
376
+ gates green β€” sustained EWMA βˆ’3.2% vs `timings` ground truth; 2-session
377
+ concurrency shows correct `aggregate_tps` β‰ˆ Ξ£ sessions + `mean_active_tps`;
378
+ `/metrics` 3 series render. bug-2205: aggregate MUST come from
379
+ Ξ£ `n_decoded_cum` (per-token), not `n_tokens_predicted_total`
380
+ (completion-time only).
381
+ - **P2 β€” pool objects + status API** (Β§3, Β§4.1-4.2). The slot/seq decoupling
382
+ (Β§3.5): seq-id reserve, prefill-only materialization task, host-side Pool
383
+ state, **auto-P attach**; `GET /polykv/pools`. Root pools only.
384
+ **βœ… IMPLEMENTED 2026-07-16** (live in vendored tree, patch capture pending):
385
+ gate 23/23 on Qwen3-4B β€” boot check (`--polykv-max-pools` without
386
+ `--kv-unified` fails clean; NOTE `--parallel` must be explicit, unset
387
+ auto-sets `kv_unified=true`), create-from-prompt prefill-only P=95,
388
+ auto-P exact attach `cache_n=95`, divergence attach `cache_n=42<95`,
389
+ `from_session` zero-copy `seq_cp` snapshot (affinity is evict-on-reuse β€”
390
+ only the most-recent session per slot resolves), pin/unpin/GET-one,
391
+ release + released-id clean fallback, disabled path inert, pooled vs
392
+ unpooled greedy text IDENTICAL (bit-shared cells). Mutations only on the
393
+ server thread via `SERVER_TASK_TYPE_POLYKV`; recurrent guard per bug-2203.
394
+ - **P3 β€” nesting & COW-fork** (Β§7). `fork` with arbitrary `D` (extend + COW
395
+ branch), cumulative-hash contract check, recursive pin, seq-id accounting.
396
+ **βœ… IMPLEMENTED 2026-07-16** (live in vendored tree, patch capture pending):
397
+ gate 18/18 substantive on Qwen3-4B β€” extend fork (D=parent.L, own=22),
398
+ COW branch (D=47<95, zero-copy `seq_cp` of `[0,D)` + fresh prefill),
399
+ contract violation β†’ clean 4xx, auto-P attach to a child (cache_n=117),
400
+ tree reporting (parent/children/branch_pos/own_len/tree_depth), release
401
+ ordering enforced (parent-with-children refused, leaves-first drains),
402
+ seq exhaustion at max_pools, `from_session` fork. The Β§4.1 prefix
403
+ contract is enforced **token-exact** (`std::equal` against the parent's
404
+ stored tokens β€” strictly subsumes the cumulative-hash check; `prefix_hash`
405
+ kept as identity fingerprint). Pooled-vs-unpooled greedy divergence on the
406
+ branch was classified benign by first-token probe (same top-5 ranking,
407
+ max |Ξ”logprob| 0.112 β€” batch-shape numerics, #495 class). Recurrent
408
+ bug-2203 guard is recursive: hybrid/recurrent forks must extend at
409
+ D == parent.L.
410
+ - **P4 β€” admission** (Β§6). `/capacity`, advisory metrics, then enforced mode
411
+ (`reject`/`warn`/`queue`).
412
+ **βœ… IMPLEMENTED 2026-07-16** (live in vendored tree, patch capture pending;
413
+ `queue` deferred per Β§11 default): gate 18/18 on Qwen3-4B β€” /capacity shape
414
+ (idle: can_admit, projected null, kv cells by OWNERSHIP: pools' own ranges +
415
+ slots' marginal suffixes via new `slot.n_pool_shared`), advisory floor never
416
+ rejects, **enforced+reject β†’ 429 + Retry-After** under an active decoding
417
+ session (tps-floor arm needs n_active β‰₯ 1), **enforced+warn β†’ 200 +
418
+ X-Sessions-Remaining**, off-path (no pool_id / unknown pool_id) untouched.
419
+ Projection = conservative fully-saturated knee: `mean_active Β· n/(n+1)`
420
+ (never over-admits below the real B_sat knee). Fast path: an atomic
421
+ enforced-pool count β€” ordinary traffic pays zero; the gate itself is a
422
+ high-priority OP_CAPACITY control task from `handle_completions_impl`
423
+ (same pattern as /slots' METRICS task).
424
+ - **P5 β€” LangGraph package** (Β§8). `opencoti-langgraph` under top-level
425
+ `opencoti/`. Runs in the first-class **`opencoti` conda env** (py3.11,
426
+ langchain-core 1.4.x + langgraph 1.2.x pinned; the same env will later
427
+ host the consultants council alongside opencoti-server). Includes the P6d
428
+ `CompactionNode` (Β§13).
429
+ **P5 βœ… IMPLEMENTED 2026-07-16**: offline 21/21 (pytest, httpx MockTransport
430
+ FakeServer), live gate **19/19** on Qwen3-4B `:8231 --parallel 2 --kv-unified
431
+ --polykv-max-pools 4` β€” pool tree (token-path fork D=65), `OpencotiChatModel`
432
+ invoke + streaming with pool attach proven (`cache_n=68 β‰₯ root prefix 65`),
433
+ `session_tps`/usage metadata, capacity context arm + gate-node routing,
434
+ enforced 429 β†’ `PoolSaturatedError(retry_after)` β†’ advisory recovery,
435
+ **P6c compact-by-refork e2e** (old pool released, migrated session coherent
436
+ on the new pool, `cache_n=71`), tps `ctx_*` fields. Contract lessons
437
+ (bug-2208): fork `prompt`/`tokens` = the child's **FULL prefix [0,L)**
438
+ token-exact vs the parent (never suffix-only β€” build via `/tokenize`:
439
+ parent tokens + suffix `add_special=false`); a pool prefix meant for
440
+ `/v1/chat/completions` attach must be the **chat-templated** system block
441
+ (raw text shares 0 tokens β†’ `cache_n=0`); Qwen3 thinking models need
442
+ `chat_template_kwargs: {enable_thinking: false}` for content-based gates.
443
+ Client accordingly ships `tokenize()` + `compact_by_refork(compacted_tokens=…)`.
444
+ - **P6 β€” context exhaustion & compaction** (Β§13). **P6a (ctx-shift pool
445
+ guard) + P6b (context visibility + /capacity context arm) land BEFORE P5**
446
+ per user decision 2026-07-16; P6c/P6d ship with P5.
447
+ **P6a+P6b βœ… IMPLEMENTED 2026-07-16** (live in vendored tree, patch capture
448
+ pending): gate 23/23 on Qwen3-4B at `-c 4096 --parallel 1 --context-shift`
449
+ (the exact Β§13.1-regime-3 configuration) β€” boot WARN; pooled session forced
450
+ past n_ctx = graceful `truncated=true` stop at 4021 tokens (position wall
451
+ fires one token before unified-cell exhaustion when the prefix is
452
+ pool-shared), NO shift, pool provably coherent afterwards (fresh attach
453
+ cache_n=60, needle answered); non-pooled ctx-shift still engages and
454
+ completes after pools released (off-path intact); `?expected_tokens` context
455
+ arm rejects with `"context headroom exhausted"`; `compaction_pressure`
456
+ 0 at idle, ∈[0,1]; `orphaned_pin` true for a pinned never-attached leaf;
457
+ `/polykv/tps` sessions carry `ctx_used/ctx_total/ctx_headroom_tokens`.
458
+ Guard bookkeeping: `slot.n_pool_shared` is clamped to the retained prefix at
459
+ prompt-cache decision time (`min(n_pool_shared, n_past)`) β€” NOT blind-reset,
460
+ because cache reuse keeps pool-shared cells across later non-pool requests
461
+ on the same session. NOTE the geometry lesson: with `--kv-unified` the slot
462
+ n_ctx is the FULL `-c` (not divided by `--parallel`), and multi-slot cell
463
+ exhaustion precedes the positional wall β€” gates must use `--parallel 1`.
464
+
465
+ ## 11. Open sub-decisions (defaulted; flag to change)
466
+
467
+ - EWMA window `W`/`T` defaults (proposed: 128 tokens or 2 s).
468
+ - Grace TTL for interior-pool reclaim (proposed: reuse `--slot-shrink-idle-ms`).
469
+ - `/metrics` opt-in flag name + default-off.
470
+ - Whether `queue` (admission) is v1 or P4-follow (proposed: `reject`+`warn` v1,
471
+ `queue` follow).
472
+ - `--polykv-max-pools` default (proposed: 16; each reserved seq-id costs KV
473
+ bookkeeping but no cells until materialized).
474
+ - Hash algorithm for the cumulative `prefix_hash` (proposed: xxhash64 rolling;
475
+ 8 B/token host RAM β€” ~800 KB for a 100k-token prefix, negligible).
476
+ - SSE tps-stream cadence (proposed: 500 ms, configurable; stream is opt-in so
477
+ off-path cost is zero).
478
+
479
+ ## 12. Verification
480
+
481
+ - Off-path identity: PolyKV-control-unused boot is byte-identical (DSO + decode).
482
+ - tps accuracy: instrumented per-session tps vs. an external wall-clock
483
+ token-rate measurement, Β±few %.
484
+ - Fork correctness: a branched pool's decode is logit-equivalent to the same
485
+ session full-prefilled (real_frac=0 / KLD), never greedy-needle.
486
+ - Tree lifetime: ancestor cells survive descendant-only references; reclaim on
487
+ refcount 0; recursive pin prevents the residency-miss fallback.
488
+ - Admission: projected vs. realized `mean_active_tps` under a spawn ramp;
489
+ `reject`/`warn` behavior + `Retry-After`/`X-Sessions-Remaining` headers.
490
+ - Composition gates: rolling-KV / DCA / turbo / MTP / `--parallel 2` unchanged.
491
+
492
+ ## 13. Context exhaustion & compaction orchestration (P6)
493
+
494
+ *Added 2026-07-16 on user review: the design above admits sessions by tps floor
495
+ and marginal KV, but had no story for what happens when the shared context
496
+ actually fills β€” and continuous agentic frameworks WILL fill it. Decision:
497
+ P6a+P6b land BEFORE P5 (the LangGraph capacity gate builds on the context arm);
498
+ P6c/P6d ship with/after P5.*
499
+
500
+ ### 13.1 What the engine does today (verified, three regimes)
501
+
502
+ 1. **Prompt too long at admission** β†’ clean `ERROR_TYPE_EXCEED_CONTEXT_SIZE`.
503
+ 2. **Generation reaches `n_ctx`, `--ctx-shift` off (default)** β†’ clean stop
504
+ with `truncated=true` (`server-context.cpp:1898`). Not corrupting, but
505
+ silent mid-thought truncation is exactly the "fails badly" mode for agents:
506
+ no advance warning, no orchestration signal.
507
+ 3. **`--ctx-shift` on + pool-attached session β†’ CORRUPTION.** The shift does
508
+ `seq_rm` + `seq_add(…, -n_discard)` (`server-context.cpp:3261-3262`); in
509
+ the unified cache `seq_add` mutates the **per-cell position**, and
510
+ pool-shared cells are members of many seqs β€” the shift re-RoPEs the pool's
511
+ prefix for every other session and every descendant pool. Upstream guards
512
+ this for parent/child shared prompts (`server-context.cpp:3192`) but a
513
+ pool attach is neither, so the guard does not fire. The rest-kv-eviction
514
+ variant uses the same primitives and has the same hazard.
515
+
516
+ The "safe" naive alternative β€” COW-copy shared cells before shifting β€”
517
+ allocates a duplicate of the prefix at the exact moment the cache is full.
518
+ Both intuited failure modes (corruption / usage inflation) are real.
519
+
520
+ ### 13.2 Design principle: compaction is a prompt REWRITE, never an in-place KV op
521
+
522
+ LangGraph (and every agentic framework) compacts at the prompt-assembly
523
+ level: `trim_messages`, `RemoveMessage` reducers, or a summarization
524
+ `pre_model_hook` that replaces old messages with a running summary. The
525
+ compacted context is **new text** β€” its tokens cannot match the old cells, so
526
+ cell-level "compaction" is not even meaningful. Therefore the server must
527
+ never mutate pooled cells; the correct primitive already exists in the tree:
528
+
529
+ > **Compaction = re-root via fork.** The system prompt + tool definitions
530
+ > survive compaction verbatim β€” that is the stable ancestor pool
531
+ > `[0, D_sys)`. Compacted context = `fork(ancestor, D=D_sys,
532
+ > tokens = summary + recent tail)` β€” a new sibling branch, prefilled once.
533
+ > Sessions migrate to the new pool; the old working subtree is `release`d.
534
+ > Pool immutability is preserved by construction, no cell position is ever
535
+ > touched, and net cells go **down** (old subtree reclaimed) instead of up
536
+ > (COW duplication).
537
+
538
+ Pinned pools under this model are a **leak hazard, not a corruption hazard**:
539
+ a pin left on the abandoned subtree blocks reclaim. Discipline is
540
+ unpin-after-migrate; the server reports orphans (pinned + zero sessions +
541
+ zero children) so the orchestrator can sweep.
542
+
543
+ ### 13.3 Phases
544
+
545
+ - **P6a β€” corruption guard (before P5).** Extend the ctx-shift refusal to
546
+ pool-attached slots (`slot.n_pool_shared > 0` β‡’ clean error, same as the
547
+ `:3192` shared-prompt refusal) + boot WARN when `--ctx-shift` is combined
548
+ with `--polykv-max-pools`. Rest-kv-eviction path included. Off-path inert.
549
+ - **P6b β€” visibility + spawn-gate parity (before P5).** Per-session
550
+ `ctx_used`/`ctx_headroom_tokens` in `/polykv/tps` + `/slots`; `/capacity`
551
+ gains `?expected_tokens=N` and a **context arm** to `can_admit`
552
+ (reason `"context headroom exhausted"`) so sub-agent spawns are gated on
553
+ context exactly like the tps floor; plus a `compaction_pressure` field
554
+ (0..1, driven by free-cell fraction and largest-session share) so the
555
+ orchestrator compacts BEFORE the wall.
556
+ - **P6c β€” compact-by-refork protocol (with P5).** Documented two-step:
557
+ summarize via normal completion β†’ `fork` the stable ancestor at `D_sys`
558
+ with the compacted suffix β†’ migrate sessions β†’ `release` old subtree.
559
+ Orphaned-pin flag in `/polykv/pools`. Atomic convenience endpoint only if
560
+ the two-step proves racy in practice.
561
+ - **P6d β€” LangGraph side (inside opencoti-langgraph).** `CompactionNode`
562
+ (pre_model_hook pattern: watch `response_metadata` headroom /
563
+ `compaction_pressure` β†’ summarize β†’ re-root β†’ swap `pool_id` in graph
564
+ state); the capacity-gate node checks the context arm from day 1.
565
+
566
+ ### 13.4 Non-goals
567
+
568
+ - In-place KV compaction/merging of pooled cells (meaningless under rewrite
569
+ semantics; corrupting under sharing).
570
+ - Server-side self-summarization (the orchestrator owns the summary β€” it has
571
+ the conversation semantics; the server only has tokens).
572
+ - Growing `n_ctx` at runtime (allocation is boot-time; capacity planning is
573
+ the admission plane's job).