Measure queued-request behaviour and reasoning_control before the M2 design

A request pinned to a busy slot receives no bytes until the slot frees.
reasoning_control ends a thinking block on demand; the capped turn is
re-read once on the next request.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 11:03:55 -07:00
co-authored by Claude Fable 5.1
parent 35a7a4e7ea
commit 362f962803
3 changed files with 113 additions and 7 deletions
+2 -2
View File
@@ -38,13 +38,13 @@ Approved by the owner on 2026-09-17. One commit each.
| P1 | Inference contract 6: the `tools` array is fixed per epoch. `find_tool` returns schemas as a tool result and the model calls them through a `call_tool(name, arguments)` meta-tool. Never instruct the model to call an undeclared tool. | M0 (h): adding a tool re-read the whole prompt; undeclared calls were coerced into `write_file`. |
| P2 | Inference contract 2: the session log stores assistant messages exactly as returned, including `reasoning_content`, and replays them unchanged. Remove the "known risk" about dropped thinking blocks. | M0 (e): Ornith's template keeps every think block. |
| P3 | Inference contract 7: requests set `return_progress: true`; progress events count as liveness. | M0 (i): otherwise the stream is silent during prefill. |
| P4 | Inference contract 8: the thinking cap uses `reasoning_control` and the control endpoint. | README b10809. Not yet exercised. |
| P4 | Inference contract 8: the thinking cap uses `reasoning_control` and the control endpoint. | README b10809. Exercised 2026-09-17, `docs/inference-contract.md` (k): it works, and a forced end costs a re-read of that one turn. |
| P5 | Settle the open question: chat-completions with server-side tool parsing. | M0 (b), (c). |
| P6 | Code constraints: `Decision` lives in `brokerd`, has a private field and does not implement `Deserialize`. `proto` carries a plain `DecisionRecord` for the audit log. | Rust privacy is per crate, and a deserializable type can be built by anyone. |
| P7 | Inference contract 1: the baseline budget test needs the server's tokenizer, so `make gate` has an offline part and an on-device part (`make verify-device`). | `/tokenize` is a server endpoint. |
| P8 | Target environment: describe the shared router as deployed (router mode, `--models-max 2`, other clients on the same Ornith instance, q8_0 KV, six-hour idle unload). Drop "memory is abundant". | `docs/inference-contract.md`, "What is running". |
| P9 | Inference contract 5: slots are pinned with `id_slot` but not reserved. Another client can evict a harness slot, and the router can unload Ornith. Cache loss is an expected event: `loopd` detects it (`cache_n` far below the previous request's total), logs it in the session log, and carries on. It is never an error. A session always uses the same slot; threads may share the main slot. | Shared router decision. M0 (d): evicted sessions came back from the host-RAM prompt cache in under a second on the same slot, and cost a full re-read on another slot. |
| P10 | Inference contract 7: a pinned request can queue behind another client's request on the same slot, and the router can spend tens of seconds reloading the model. The liveness timer starts at the first byte received, and a separate, longer "waiting for slot or model" limit covers the time before it. | Shared router decision. Needs a measurement of what the stream sends while queued. |
| P10 | Inference contract 7: a pinned request can queue behind another client's request on the same slot, and the router can spend tens of seconds reloading the model. The liveness timer starts at the first byte received, and a separate, longer "waiting for slot or model" limit covers the time before it. | Shared router decision. Measured 2026-09-17, `docs/inference-contract.md` (j): a queued request receives no bytes at all; `/slots` shows whether the slot is busy. |
| P11 | Inference contract 10: the serving flags live in `~/src/nixos`, not here. This repo records the expected values (template hash, per-slot `n_ctx`, slot count, KV type, sampling) and the startup self-test compares them with `/props` and `/slots`. KV type is recorded as q8_0. | Shared router decision. |
| P12 | Network isolation 2: `inferproxy` is kept. Remove the conditional about dropping it. Its upstream is the router's TCP listener on the host. | Shared router decision; M0 (g). |
+24 -4
View File
@@ -98,12 +98,32 @@ are on the same host. In router mode the router sets each child's host and port
router's public listener could move to a socket, and Open WebUI, OpenCode and Tailscale Serve need
it on TCP. `loopd` runs with `--network=none` and cannot reach host loopback. So `inferproxy` stays.
### Runaway control
### (j) What a queued request receives: nothing
Measured 2026-09-17 before the M2 design. Request A generated on slot 0 for 10 s. Request B, pinned
to the same slot with `stream` and `return_progress`, was sent while A was running. B received no
bytes at all, not even response headers, until A finished: first byte at 10.02 s, then progress
events and tokens as usual. The stream cannot tell "queued" from "dead". `GET /slots?model=…` can:
it shows `is_processing` for the slot. So the wait before the first byte needs its own, longer
limit, and `loopd` can poll `/slots` during it to tell a busy slot from a dead server.
### (k) Runaway control: `reasoning_control` works, and costs one turn of cache
`--reasoning-budget` is a server flag, not a request field. Per request there is `max_tokens`, and
`reasoning_control: true` plus `POST /v1/chat/completions/control` with `action: "reasoning_end"`,
which ends the thinking block of a running completion. The second one fits a per-turn thinking cap
enforced by `loopd` while it counts streamed reasoning tokens. Not yet exercised.
`reasoning_control: true` plus `POST /v1/chat/completions/control` with the completion's `id`,
`action: "reasoning_end"` and `model`.
Measured 2026-09-17: the control call was sent after 150 streamed reasoning chunks and returned
`{"success": true}`. Three more reasoning chunks arrived, then the model wrote its answer and
finished with `finish_reason: "stop"`.
The next turn, with the capped turn replayed exactly as streamed, had `cache_n` 85 and `prompt_n`
743: the server re-read the whole capped assistant turn. The same two turns with a natural end to
thinking gave `cache_n` 167, `prompt_n` 22. So what the server generated at a forced end is not
what the template renders from the replayed message, and the hybrid cache falls back to the
checkpoint at the end of the previous prompt. The cost is bounded by the thinking cap plus one
answer, and it is paid once. The control run also shows that streamed deltas, concatenated, replay
byte for byte.
### (a) Throughput