164 lines
6.6 KiB
Markdown
164 lines
6.6 KiB
Markdown
# 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<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 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
|