Files
poimen-memory/tasks/M1.5-gated-loop.md
T

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.rsrun_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:

// 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

  1. run_loop(level, query, source: impl Stream<Item = Chunk>, 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 testtests/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_yesyes at turn 3 only; assert memory equals turn 3's candidate and chunks_used == 1.
  3. a3_exit_gate_off_reads_allend 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 — Architecture, tier model · paper Alg 1