# M1.5 — The gated loop | Field | Value | |---|---| | Phase | M1 — Gated loop at L1 | | Size | L — 3+ days | | Status | ✅ Done | | Flags | — | | Spec | inlined below | | Blocks | M1.4 | ## Goal The recurrence itself: `U_t, M̂_t, E_t = φθ(Q, C_t, M_{t-1})`, with the level as a parameter so L2 reuses it unchanged. ## Files | Action | Path | |---|---| | Create | `crates/mem-core/src/gated_loop.rs` — `run_loop()`, `LoopConfig`, `LoopEvent`, `RunOutcome` | | Modify | `crates/mem-core/src/lib.rs` — add `pub mod gated_loop;` and re-exports | | Create | `tests/it_gated_loop.rs` — integration tests (workspace root, 10 assertions) | ## Dependencies | Crate | Where | Already present? | |---|---|---| | `async-trait` | `crates/mem-core/Cargo.toml` | ❌ add — for `LlmClient` trait | Or use `impl Future` return types and avoid the dependency. ## Existing code to reuse - `Chunk`, `Level`, `Sha256Hash` from `domain.rs` — input/output types - `Query` from `query.rs` (M1.2) — the standing question - `PromptBuilder` from `prompt.rs` (M1.3) — assemble the prompt per turn - `parse_gate_response` from `gate_parser.rs` (M1.4) — parse LLM output - `ChatClient` from `mem-llm/src/chat.rs` (M1.1) — call the LLM - `TokenCounter` from `mem-chunk/src/token_counter.rs` — measure candidate memory tokens ## Dependency injection The loop needs an LLM client, but tests must use a scripted fake. Define a trait: ```rust // in gated_loop.rs pub trait LlmClient: Send + Sync { fn complete(&self, system: &str, user: &str, max_tokens: usize) -> impl std::future::Future> + Send; } ``` `ChatClient` implements it. Tests use a `ScriptedClient` that returns canned responses indexed by turn number. ## Facts (inlined — no spec read needed) Paper Algorithm 1, transcribed: ``` t <- 1; M_0 <- None while t <= T: U_t, M̂_t, E_t = φθ(Q, C_t, M_{t-1}) if U_t == True: M_t <- M̂_t # update else: M_t <- M_{t-1} # retain, discard chunk if use_exit_gate and E_t == True: break t <- t + 1 answer = ψθ(Q, M_t) ``` Two decisions this task must not get wrong: **`use_exit_gate` is a parameter, false at L1.** `E_t` is always *recorded* regardless — its signal is the M5 training target, and on a future unbounded stream it becomes the only termination condition. Recording a gate you do not act on is deliberate, not dead code. **The memory budget is enforced by the loop, not hoped for from the model.** `memory_budget` is 1024 tokens. If `M̂_t` exceeds it the loop does **not** silently truncate — truncation mid-sentence corrupts the memory for every later turn. It records a `budget_exceeded` event and retains `M_{t-1}`, treating the turn as `U_t = false`. Memory that stops growing is recoverable; memory that is truncated garbage is not. The loop is generic over the input stream, so L2 (M3.1) passes L1 memories in place of chunks with no other change. ## Steps 1. `run_loop(level, query, source: impl Stream, cfg) -> RunOutcome` in `mem-core`. 2. Per turn: build prompt (M1.3), call model (M1.1), parse (M1.4), apply the update rule, emit events. 3. On parse error: retry the same chunk up to 2 times. Still failing, record `parse_failed`, treat as `U_t = false`, continue. **Never** default the gate. 4. On `U_t = true`: emit an L0 `evidence` event for the chunk, then an L1 `memory` event whose `parents` include that evidence sha plus the previous memory's sha. 5. Enforce `memory_budget` as above. 6. Honour `E_t` only when `use_exit_gate`; always record it. 7. Emit `run_end` with `chunks_seen`, `chunks_used`, `final_memory_sha`. 8. Cancellation: a dropped future must not leave a half-written log. Emit events only after a turn fully resolves. ## Acceptance - `U_t = false` leaves memory byte-identical to the previous turn. - `U_t = true` replaces memory and links parents. - `exit_gate = false` processes every chunk even when `E_t = true` throughout. - Over-budget candidate retains prior memory and records the event. - Two parse failures then success consumes 3 calls for one chunk. ## Verify **Harness:** a scripted fake `ChatClient` returning canned responses per turn, so the whole loop runs with no network and fully determined gate sequences. **Integration test** — `tests/it_gated_loop.rs`: 1. `a1_retain_on_no` — scripted `no` for 5 turns; assert final memory equals initial and `chunks_used == 0`. 2. `a2_update_on_yes` — `yes` at turn 3 only; assert memory equals turn 3's candidate and `chunks_used == 1`. 3. `a3_exit_gate_off_reads_all` — `end` at every turn with `use_exit_gate=false`; assert all 10 chunks processed. 4. `a4_exit_gate_on_stops` — same script, `use_exit_gate=true`; assert it stops at turn 1. 5. `a5_exit_always_recorded` — in a3, assert 10 `gate` events carry `exit=true` despite not acting on them. 6. `a6_budget_exceeded_retains` — candidate of 4000 tokens; assert memory unchanged, one `budget_exceeded` event, turn counted as not-used. 7. `a7_parse_retry` — fail twice then succeed; assert 3 calls, one memory event. 8. `a8_parse_failure_is_not_an_update` — fail 3 times; assert `U_t` false, `parse_failed` recorded, loop continues. 9. `a9_parents_linked` — every memory event's `parents` contains the evidence sha from the same turn. 10. `a10_level_is_a_parameter` — run the identical script at L1 and L2; assert the only difference in emitted events is the `level` field. **Command:** `cargo test --test it_gated_loop` **False pass:** - Testing with a fake that always returns `yes`. Every assertion about the retain path is skipped, and retain is the path that matters — it is what makes this a gate rather than a summarizer. - Asserting `chunks_used` without asserting memory bytes. A loop that updates memory on `no` but counts correctly passes a count-only test; assertion 1 compares bytes. - Omitting assertion 10. Without it, L2 in M3.1 becomes a copy of this loop, and the two drift. ## Traps - Truncating over-budget memory to fit. It corrupts every subsequent turn's input, and the damage is attributed to the model. - Acting on `E_t` at L1 because "the model said it had enough". Paper §3.3 is explicit that this is wrong for exhaustive questions, and it silently truncates extraction in a way that looks like poor recall. - Writing log events before the turn resolves. A cancelled run then leaves a memory event with no matching gate event and `mem verify` fails on a file that was merely interrupted. --- Background: [DESIGN.md](../DESIGN.md) — Architecture, tier model · paper Alg 1