Add M2a plan: thirteen tasks, tests, fake server and recordings

The tasks build the inference path: emsha-backed SHA-256, inferproxy,
config, a hand-written HTTP and SSE client, request building, delta
assembly, the chat state machine, the thinking cap, the slot gate with
retry, the startup self-test and on-device verification.

Everything the tasks copy in was checked against a private reference
implementation: the gate passes after each task in order, the timing
tests pass repeatedly under CPU load, and the reference passes the
self-test and all four device checks on straylight. Expected results
for the recorded streams were derived by a separate script.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 13:34:11 -07:00
co-authored by Claude Fable 5.1
parent a49db39b54
commit 76ccc251cd
56 changed files with 6367 additions and 3 deletions
+146
View File
@@ -0,0 +1,146 @@
# M2a task 03: `loopd` configuration
**Branch:** `m2a` (run `git switch m2a`; `git status --short` must be empty, otherwise stop)
**Commit subject:** `Add loopd configuration with every M2a limit`
## Goal
Read `config.toml` into a typed `Config`. Every limit the inference client uses is a field here
with a default, so that the owner can change it in one line and tests can make it milliseconds.
## Context
This is **our own format**, so unknown keys are errors, in every table. The reason is practical:
a misspelt `livenes_ms` that silently fell back to its default would be a limit the owner believes
is set and is not. The test checks every table separately.
A full file:
```toml
[infer]
socket = "/run/boxmaker/infer/infer.sock" # where inferproxy listens
model = "ornith-1.5-35b-a3b" # the id the router knows the model by
[slots]
main = 0
background = 1
[expect] # what the server must report; checked at startup
template_sha256 = "f55f52930aa8bf44ab5cb85f99370fcc3c56e9a85640b812086d5330bce5d86b"
n_ctx = 131072
slots = 2
[sampling] # optional; these are the defaults
temperature = 0.6
top_p = 0.95
top_k = 20
[limits] # optional; see the table below for the defaults
liveness_ms = 30000
```
## Files
- Copy: `crates/loopd/tests/config.rs`, `crates/loopd/tests/fixtures/config/` (2 files)
- Create: `crates/loopd/src/config.rs`
- Modify: `crates/loopd/Cargo.toml`, `crates/loopd/src/lib.rs`, `docs/implementer-log.md`,
`Cargo.lock` (generated)
## Interfaces
Produces, in `crates/loopd/src/config.rs`. All fields are `pub`.
```rust
pub struct Config { infer: Infer, slots: Slots, expect: Expect, sampling: Sampling, limits: Limits }
pub struct Infer { socket: std::path::PathBuf, model: String }
pub struct Slots { main: u32, background: u32 }
pub struct Expect { template_sha256: proto::Hash32, n_ctx: u64, slots: u32 }
pub struct Sampling { temperature: f64, top_p: f64, top_k: u32 }
pub struct Limits { /* twelve fields, below */ }
pub enum ConfigError { Read(PathBuf, std::io::Error), Parse(PathBuf, toml::de::Error) }
impl Config {
pub fn parse(text: &str) -> Result<Config, toml::de::Error>;
pub fn load(path: &std::path::Path) -> Result<Config, ConfigError>;
}
```
`[infer]`, `[slots]` and `[expect]` are required. `[sampling]` and `[limits]` are optional, and
inside them every key is optional: a file that sets one limit keeps the defaults for the others.
| `Limits` field | Type | Default | Meaning |
|---|---|---|---|
| `poll_ms` | `u64` | 5000 | Read timeout while no byte has arrived; one `/slots` poll per timeout |
| `busy_wait_ms` | `u64` | 600000 | How long a busy slot is waited for |
| `load_wait_ms` | `u64` | 180000 | How long an unavailable server is waited for |
| `idle_grace_ms` | `u64` | 30000 | How long an idle slot may stay silent |
| `liveness_ms` | `u64` | 30000 | Silence allowed once bytes are arriving |
| `thinking_cap` | `u64` | 4096 | Reasoning tokens before the cap fires |
| `thinking_overrun` | `u64` | 256 | Reasoning tokens allowed after the cap fired |
| `max_tokens` | `u64` | 8192 | Backstop sent with every request |
| `queue_len` | `usize` | 8 | Requests that may wait for one slot |
| `retry_attempts` | `u32` | 4 | Attempts in all, the first included |
| `retry_backoff_ms` | `Vec<u64>` | `[2000, 8000, 30000]` | Wait before each retry |
| `retry_window_ms` | `u64` | 300000 | No retry starts later than this |
Derives: `Debug, Clone, PartialEq, Deserialize` on all five structs, plus `Eq` on the three that
hold no `f64` (`Infer`, `Expect`, `Limits`), plus `Copy` and `Eq` on `Slots`, plus `Copy` on
`Sampling`. `Sampling` and `Limits` implement `Default` by hand with the values above.
`ConfigError` derives `Debug` and implements `Display` (the message names the file) and
`std::error::Error`.
## API notes (serde 1.0, verified)
- `#[serde(deny_unknown_fields)]` goes on **all five** structs. The test adds an unknown key to
each table in turn, and at the top level, and as a whole unknown table.
- `#[serde(default)]` on a **field** uses the field type's `Default` when the key (here: the whole
table) is missing. `#[serde(default)]` on a **struct** fills each missing field from the
struct's own `Default` impl; that is what makes a partial `[limits]` table work. The two combine
with `deny_unknown_fields` in one attribute: `#[serde(deny_unknown_fields, default)]`.
- `proto::Hash32` already deserializes from a 64-character lowercase hex string and rejects
anything else.
## Steps
- [ ] **1. Copy the test and fixtures, add the dependencies.**
```sh
git switch m2a
mkdir -p crates/loopd/tests/fixtures
cp docs/plans/M2a/files/crates/loopd/tests/config.rs crates/loopd/tests/
cp -r docs/plans/M2a/files/crates/loopd/tests/fixtures/config crates/loopd/tests/fixtures/
```
Add to `[dependencies]` in `crates/loopd/Cargo.toml`, below `proto.workspace = true`:
```toml
serde.workspace = true
serde_json.workspace = true
toml.workspace = true
```
All three are already in the workspace table and in `docs/dependencies.md`. `serde_json` is not
used until the next task; add it now so that the manifest is touched once.
- [ ] **2. See the test fail.** `cargo test -p loopd --test config`. Expected: it does not compile.
- [ ] **3. Write `config.rs`,** and add `pub mod config;` to `crates/loopd/src/lib.rs` below the doc
comment. Run `cargo fmt --all`.
- [ ] **4. See the test pass.** `cargo test -p loopd --test config`. Expected: `6 passed`.
- [ ] **5. Check the "everywhere" rule yourself.** List the structs in `config.rs` and confirm each
of the five has `deny_unknown_fields`. Write the list in your log row.
- [ ] **6. Run the gate.** `cargo build`, then `make gate`. Expected last line: `gate: ok`.
- [ ] **7. Log and commit.**
```sh
git add Cargo.lock crates/loopd docs/implementer-log.md
git commit
```
## Done when
- `cargo test -p loopd --test config` reports 6 passed; `make gate` prints `gate: ok`.
## Stop and report if
- `deny_unknown_fields` and a struct-level `default` cannot be combined as described.