126 lines
5.1 KiB
Markdown
126 lines
5.1 KiB
Markdown
# M1.5 — The gated loop
|
|
|
|
| Field | Value |
|
|
|---|---|
|
|
| Phase | M1 — Gated loop at L1 |
|
|
| Size | L — 3+ days |
|
|
| Status | ⬜ Not started |
|
|
| 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.
|
|
|
|
## 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 -p mem-core 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
|