Files
poimen-memory/tasks/M1.4-gate-response-parser.md
T

155 lines
5.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# M1.4 — Gate-response parser
| Field | Value |
|---|---|
| Phase | M1 — Gated loop at L1 |
| Size | M — 13 days |
| Status | ✅ Done |
| Flags | — |
| Spec | inlined below |
| Blocks | M1.3 |
## Goal
Turn the model's tagged output into `(U_t, M̂_t, E_t)`, strictly — because a
lenient parser silently fabricates gate decisions.
## Files
| Action | Path |
|---|---|
| Create | `crates/mem-core/src/gate_parser.rs``parse_gate_response()`, `GateResponse`, `ParseError` |
| Modify | `crates/mem-core/src/lib.rs` — add `pub mod gate_parser;` and re-exports |
| Create | `fixtures/gate-response-valid.txt` — well-formed model output |
| Create | `fixtures/gate-response-nested-think.txt` — nested `<think>` tags |
| Create | `tests/it_gate_parser.rs` — integration tests (workspace root) |
## Dependencies
**None new.** Use `str::find()` and `str::rfind()` for tag extraction. No regex
crate needed — the tags are simple XML-like delimiters, not a grammar.
## Existing code to reuse
- Pattern reference: `lesson.rs` uses similar string scanning for error markers
(`markers()`, `GENERIC_MARKERS`). Same technique, different tags.
- `thiserror` already in `mem-core` deps for error enum derivation.
## Expected input/output
```
Input:
<think>
This chunk shows a kubectl error. The user fixed it by adding --namespace.
</think>
<check>yes</check>
<update>kubectl get pods fails without --namespace; fixed by adding --namespace=kube-system</update>
<next>continue</next>
Output:
GateResponse {
think: "This chunk shows a kubectl error. The user fixed it by adding --namespace.",
update_gate: true,
candidate: "kubectl get pods fails without --namespace; fixed by adding --namespace=kube-system",
exit_gate: false,
}
```
## Facts (inlined — no spec read needed)
Expected response shape:
```
<think>...</think>
<check>yes|no</check>
<update>candidate memory, or the previous memory verbatim</update>
<next>continue|end</next>
```
Semantics, from the paper:
| tag | value | meaning |
|---|---|---|
| `<check>` | `yes` | `U_t = true` — memory becomes `M̂_t` |
| `<check>` | `no` | `U_t = false` — memory stays `M_{t-1}`, chunk discarded |
| `<next>` | `continue` | `E_t = false` |
| `<next>` | `end` | `E_t = true` |
**Strict parsing is the design, matching the paper's `r_format`:** it awards 1
only when *every* turn in the trajectory parses, 0 otherwise, "because we can not
infer whether the incorrect format is caused by the previous erroneous parsing".
So: exactly one of each tag, properly closed, `<check>` content exactly `yes` or
`no` after trimming, `<next>` exactly `continue` or `end`. Anything else is a
`ParseError` naming which tag failed and carrying the raw text.
**A parse failure must not default.** Defaulting `U_t` to `false` silently drops
evidence; defaulting to `true` pollutes memory. The loop (M1.5) decides the
retry policy; the parser only reports.
Reasoning models emit `<think>` natively, which can nest or repeat. Extract by
locating the *last* `</think>` before the first `<check>`, not by regex over the
whole body.
## Steps
1. `parse_gate_response(&str) -> Result<GateResponse, ParseError>` in `mem-core`.
2. `GateResponse { think: String, update_gate: bool, candidate: String, exit_gate: bool }`.
3. Reject duplicates of any tag, a missing tag, an unclosed tag, and any
`<check>`/`<next>` value outside the allowed set.
4. `ParseError` variants name the tag and include the raw response, truncated.
5. Trim surrounding whitespace inside tags; do not otherwise normalise — memory
text is preserved verbatim.
6. When `U_t = false`, still capture `candidate` so the log can show what the
model *would* have written. The loop ignores it; the record is diagnostic.
## Acceptance
- All four tags parse from a well-formed response.
- Every malformed shape errors, naming the failing tag.
- No input produces a default `U_t` or `E_t`.
## Verify
**Harness:** table-driven over recorded real responses plus hand-built malformed
cases. Capture real ones with `MEM_LLM_RECORD` from M1.1.
**Integration test**`tests/it_gate_parser.rs`:
1. `a1_wellformed_yes_continue``U=true`, `E=false`, candidate matches.
2. `a2_wellformed_no_end``U=false`, `E=true`.
3. `a3_missing_check_errors` — error names `check`.
4. `a4_duplicate_update_errors` — two `<update>` blocks error.
5. `a5_bad_check_value_errors``<check>maybe</check>` errors, message shows the
value.
6. `a6_unclosed_tag_errors``<update>` never closed.
7. `a7_nested_think` — a response with `<think>` inside `<think>` still finds the
right boundary.
8. `a8_no_defaults` — property test over 1000 random mutations of a valid
response: every result is either an exact parse or an error, never a
silently-defaulted `GateResponse`.
9. `a9_real_responses` — every recorded real response parses.
**Command:** `cargo test --test it_gate_parser`
**False pass:**
- A regex that finds the first `<check>` and stops. It passes 12 and silently
accepts duplicates, which is assertion 4's job.
- Testing only hand-written responses. Real 3B output has whitespace,
markdown fences and stray prose the author would not think to write —
assertion 9 is the only one that sees it.
- Omitting assertion 8. A parser with `unwrap_or(false)` anywhere passes every
positive test and quietly halves the update rate.
## Traps
- Defaulting on parse failure. The one thing that must not happen: a fabricated
gate decision is indistinguishable from a real one in the log, and it poisons
the M5 training data at the source.
- Normalising the candidate memory (collapsing whitespace, stripping markdown).
Memory text is content; the hash and every downstream projection depend on it
being verbatim.
---
Background: [DESIGN.md](../DESIGN.md) — Architecture · paper §3.2.1 `r_format`