Files

155 lines
5.8 KiB
Markdown
Raw Permalink Normal View History

2026-08-19 09:52:07 -07:00
# M1.4 — Gate-response parser
| Field | Value |
|---|---|
| Phase | M1 — Gated loop at L1 |
| Size | M — 13 days |
| Status | ✅ Done |
2026-08-19 09:52:07 -07:00
| 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,
}
```
2026-08-19 09:52:07 -07:00
## 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`
2026-08-19 09:52:07 -07:00
**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`