6.6 KiB
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,Sha256Hashfromdomain.rs— input/output typesQueryfromquery.rs(M1.2) — the standing questionPromptBuilderfromprompt.rs(M1.3) — assemble the prompt per turnparse_gate_responsefromgate_parser.rs(M1.4) — parse LLM outputChatClientfrommem-llm/src/chat.rs(M1.1) — call the LLMTokenCounterfrommem-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:
// in gated_loop.rs
pub trait LlmClient: Send + Sync {
fn complete(&self, system: &str, user: &str, max_tokens: usize)
-> impl std::future::Future<Output = anyhow::Result<mem_llm::Completion>> + 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
run_loop(level, query, source: impl Stream<Item = Chunk>, cfg) -> RunOutcomeinmem-core.- Per turn: build prompt (M1.3), call model (M1.1), parse (M1.4), apply the update rule, emit events.
- On parse error: retry the same chunk up to 2 times. Still failing, record
parse_failed, treat asU_t = false, continue. Never default the gate. - On
U_t = true: emit an L0evidenceevent for the chunk, then an L1memoryevent whoseparentsinclude that evidence sha plus the previous memory's sha. - Enforce
memory_budgetas above. - Honour
E_tonly whenuse_exit_gate; always record it. - Emit
run_endwithchunks_seen,chunks_used,final_memory_sha. - Cancellation: a dropped future must not leave a half-written log. Emit events only after a turn fully resolves.
Acceptance
U_t = falseleaves memory byte-identical to the previous turn.U_t = truereplaces memory and links parents.exit_gate = falseprocesses every chunk even whenE_t = truethroughout.- 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:
a1_retain_on_no— scriptednofor 5 turns; assert final memory equals initial andchunks_used == 0.a2_update_on_yes—yesat turn 3 only; assert memory equals turn 3's candidate andchunks_used == 1.a3_exit_gate_off_reads_all—endat every turn withuse_exit_gate=false; assert all 10 chunks processed.a4_exit_gate_on_stops— same script,use_exit_gate=true; assert it stops at turn 1.a5_exit_always_recorded— in a3, assert 10gateevents carryexit=truedespite not acting on them.a6_budget_exceeded_retains— candidate of 4000 tokens; assert memory unchanged, onebudget_exceededevent, turn counted as not-used.a7_parse_retry— fail twice then succeed; assert 3 calls, one memory event.a8_parse_failure_is_not_an_update— fail 3 times; assertU_tfalse,parse_failedrecorded, loop continues.a9_parents_linked— every memory event'sparentscontains the evidence sha from the same turn.a10_level_is_a_parameter— run the identical script at L1 and L2; assert the only difference in emitted events is thelevelfield.
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_usedwithout asserting memory bytes. A loop that updates memory onnobut 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_tat 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 verifyfails on a file that was merely interrupted.
Background: DESIGN.md — Architecture, tier model · paper Alg 1