# 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; pub fn load(path: &std::path::Path) -> Result; } ``` `[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` | `[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.