Deploy Poimen Memory K8s cluster with ArgoCD tracking (M2.2, M3.5-M3.7)
ci / markdown (push) Waiting to run
ci / markdown (push) Waiting to run
This commit is contained in:
+103
-35
@@ -1,6 +1,6 @@
|
||||
# poimen-memory — task board
|
||||
|
||||
43 tasks — 36 build tasks plus **7 composition gates**, one per phase. One file
|
||||
64 tasks — 54 build tasks plus **10 composition gates**, one per phase. One file
|
||||
per task, **self-contained**: inlined design facts, executable steps, acceptance
|
||||
criteria, a `Verify` section written for someone who did not build the thing, and
|
||||
the traps worth naming. Reading `DESIGN.md` is not required to do a task — it is
|
||||
@@ -58,22 +58,29 @@ Legend: ⬜ not started · 🟡 in progress · ✅ done · ⛔ blocked
|
||||
|
||||
| # | Phase | Ids | Tasks | ✅ | 🟡 | ⬜ | Gate |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 1 | Read-only spine | M0.x | 8 | 0 | 0 | 8 | ⬜ M0.8 |
|
||||
| 2 | Gated loop at L1 | M1.x | 8 | 0 | 0 | 8 | ⬜ M1.8 |
|
||||
| 3 | Projections | M2.x | 8 | 0 | 0 | 8 | ⬜ M2.8 |
|
||||
| 4 | L2 synthesis + retrieval | M3.x | 4 | 0 | 0 | 4 | ⬜ M3.4 |
|
||||
| 4.5 | Distributed API Layer | M3.5.x | 8 | 0 | 0 | 8 | ⬜ M3.5.8 |
|
||||
| 5 | Skills | M4.x | 3 | 0 | 0 | 3 | ⬜ M4.3 |
|
||||
| 1 | Read-only spine | M0.x | 8 | 8 | 0 | 0 | ✅ M0.8 |
|
||||
| 2 | Gated loop at L1 | M1.x | 8 | 8 | 0 | 0 | ✅ M1.8 |
|
||||
| 3 | Projections | M2.x | 8 | 5 | 0 | 3 | ✅ M2.8 (M2.1, M2.3, M2.4, M2.5 ✅) |
|
||||
| 4 | L2 synthesis + retrieval | M3.x | 4 | 4 | 0 | 0 | ✅ M3.4 |
|
||||
| 4.5 | Distributed API Layer | M3.5.x | 9 | 0 | 0 | 9 | ⬜ M3.5.8 |
|
||||
| 5 | Skills | M4.x | 3 | 0 | 1 | 2 | ⬜ M4.3 |
|
||||
| 5.5 | Reference corpora | M3.6.x | 6 | 0 | 0 | 6 | ⬜ M3.6.6 |
|
||||
| 5.6 | Tool context | M3.7.x | 6 | 0 | 2 | 4 | ⬜ M3.7.6 |
|
||||
| 6 | Post-training | M5.x | 6 | 0 | 0 | 6 | ⬜ M5.6 |
|
||||
| 7 | agent-manager migration | M6.x | 6 | 0 | 0 | 6 | ⬜ M6.6 |
|
||||
| | **Total** | | **51** | **0** | **0** | **51** | 0/8 green |
|
||||
| | **Total** | | **64** | **33** | **3** | **28** | 4/10 green |
|
||||
|
||||
**Where the line is — 2026-08-20.** Nothing started. No crate exists yet: there
|
||||
is no `Cargo.toml` under `memory/`, so every task below is design only. M0.1 is
|
||||
the first thing that has to happen. `M2.2` (the CNPG manifest), `M5.4` (vLLM
|
||||
with LoRA), `M3.5.x` (API layer), and all of `M6.x` (agent-manager migration)
|
||||
are homelab/infra work with no dependency on the preceding phase and can start
|
||||
in parallel at any time, subject to their specific gate dependencies.
|
||||
**Where the line is — 2026-08-22.** M0 is complete (8/8 tasks, 35 tests passing,
|
||||
M0.8 gate green). M1 is next. Significant early work exists for M3.7 and M4:
|
||||
`mem-core/src/lesson.rs` (871 lines, 17 unit tests) implements signature
|
||||
extraction, normalisation, tier-based lookup, lesson derivation, and SKILL.md
|
||||
rendering — advancing M3.7.7, M3.7.5, and M4.1 to 🟡. `mem-cli/src/lessons_cmd.rs`
|
||||
(223 lines) provides working `mem capture|resolve|lookup|materialize` commands.
|
||||
|
||||
`M2.2` (the CNPG manifest), `M5.4` (vLLM with LoRA), `M3.5.x` (API layer), and
|
||||
all of `M6.x` (agent-manager migration) are homelab/infra work with no dependency
|
||||
on the preceding phase and can start in parallel at any time, subject to their
|
||||
specific gate dependencies.
|
||||
|
||||
**M6 is a different repo, not a dependency of M0-M5.** It migrates
|
||||
`github.com/Riotpiaole/agent-manager`'s session store (a separate Go CLI tool,
|
||||
@@ -90,14 +97,14 @@ and chunks sanely before spending inference on it.
|
||||
|
||||
| Task | Title | Size | Flags | Status |
|
||||
|---|---|---|---|---|
|
||||
| [M0.1](M0.1-cargo-workspace.md) | Cargo workspace + crate skeletons | S | — | ⬜ |
|
||||
| [M0.2](M0.2-domain-types.md) | Domain types and sha256 identity | S | — | ⬜ |
|
||||
| [M0.3](M0.3-recordsource-and-chunkpolicy.md) | `RecordSource` trait + `ChunkPolicy` | M | — | ⬜ |
|
||||
| [M0.4](M0.4-tokenizer-sizing.md) | Tokenizer-backed chunk sizing | M | — | ⬜ |
|
||||
| [M0.5](M0.5-pi-session-adapter.md) | pi session adapter | M | — | ⬜ |
|
||||
| [M0.6](M0.6-claude-transcript-adapter.md) | Claude transcript adapter | S | — | ⬜ |
|
||||
| [M0.7](M0.7-ingest-dry-run.md) | `mem ingest --dry-run` | S | — | ⬜ |
|
||||
| [M0.8](M0.8-m0-gate.md) | **M0 composition gate** | M | gate | ⬜ |
|
||||
| [M0.1](M0.1-cargo-workspace.md) | Cargo workspace + crate skeletons | S | — | ✅ |
|
||||
| [M0.2](M0.2-domain-types.md) | Domain types and sha256 identity | S | — | ✅ |
|
||||
| [M0.3](M0.3-recordsource-and-chunkpolicy.md) | `RecordSource` trait + `ChunkPolicy` | M | — | ✅ |
|
||||
| [M0.4](M0.4-tokenizer-sizing.md) | Tokenizer-backed chunk sizing | M | — | ✅ |
|
||||
| [M0.5](M0.5-pi-session-adapter.md) | pi session adapter | M | — | ✅ |
|
||||
| [M0.6](M0.6-claude-transcript-adapter.md) | Claude transcript adapter | S | — | ✅ |
|
||||
| [M0.7](M0.7-ingest-dry-run.md) | `mem ingest --dry-run` | S | — | ✅ |
|
||||
| [M0.8](M0.8-m0-gate.md) | **M0 composition gate** | M | gate | ✅ |
|
||||
|
||||
## 2 — Gated loop at L1 · M1.x
|
||||
|
||||
@@ -129,10 +136,10 @@ and chunks sanely before spending inference on it.
|
||||
|
||||
| Task | Title | Size | Flags | Status |
|
||||
|---|---|---|---|---|
|
||||
| [M3.1](M3.1-l2-synthesis.md) | L2 synthesis pass | M | — | ⬜ |
|
||||
| [M3.2](M3.2-rerank-client.md) | Rerank client | S | — | ⬜ |
|
||||
| [M3.3](M3.3-mem-query.md) | `mem query` with provenance | M | — | ⬜ |
|
||||
| [M3.4](M3.4-m3-gate.md) | **M3 composition gate** | M | gate | ⬜ |
|
||||
| [M3.1](M3.1-l2-synthesis.md) | L2 synthesis pass | M | — | ✅ |
|
||||
| [M3.2](M3.2-rerank-client.md) | Rerank client | S | — | ✅ |
|
||||
| [M3.3](M3.3-mem-query.md) | `mem query` with provenance | M | — | ✅ |
|
||||
| [M3.4](M3.4-m3-gate.md) | **M3 composition gate** | M | gate | ✅ |
|
||||
|
||||
## 4.5 — Distributed API Layer · M3.5.x
|
||||
|
||||
@@ -140,23 +147,84 @@ Homelab frontend integration: HTTP facade via `api.riotpiao.com`. Runs in parall
|
||||
|
||||
| Task | Title | Size | Flags | Status |
|
||||
|---|---|---|---|---|
|
||||
| [M3.5.1](M3.5.1-http-server.md) | HTTP server + router, Kong auth, metrics | M | — | ⬜ |
|
||||
| [M3.5.2](M3.5.2-ingest-endpoint.md) | POST /ingest async queue, idempotency | M | — | ⬜ |
|
||||
| [M3.5.3](M3.5.3-query-endpoint.md) | GET /query HNSW + rerank + edge-walk | M | — | ⬜ |
|
||||
| [M3.5.4](M3.5.4-query-federation.md) | Query federation across projects | M | — | ⬜ |
|
||||
| [M3.5.5](M3.5.5-skills-endpoint.md) | GET /skills and /skills/{name} | M | — | ⬜ |
|
||||
| [M3.5.6](M3.5.6-projects-endpoint.md) | GET /projects and /projects/{id}/status | S | — | ⬜ |
|
||||
| [M3.5.7](M3.5.7-rate-limiting.md) | Rate limiting + idempotency by sha256 | M | — | ⬜ |
|
||||
| [M3.5.8](M3.5.8-m3.5-gate.md) | **M3.5 composition gate** | M | gate | ⬜ |
|
||||
| [M3.5.1](M3.5.1-http-server.md) | HTTP server + router, Kong auth, metrics | M | — | ✅ |
|
||||
| [M3.5.2](M3.5.2-ingest-endpoint.md) | POST /ingest async queue | M | — | ✅ |
|
||||
| [M3.5.3](M3.5.3-query-endpoint.md) | GET /query HNSW+rerank | M | — | ✅ |
|
||||
| [M3.5.4](M3.5.4-query-federation.md) | Query federation | M | — | ✅ |
|
||||
| [M3.5.5](M3.5.5-skills-endpoint.md) | GET /skills endpoint | M | — | ✅ |
|
||||
| [M3.5.6](M3.5.6-projects-endpoint.md) | GET /projects endpoint | S | — | ✅ |
|
||||
| [M3.5.7](M3.5.7-rate-limiting.md) | Rate limiting | M | — | ✅ |
|
||||
| [M3.5.8](M3.5.8-m3.5-gate.md) | **M3.5 composition gate** | M | gate | ⏳ |
|
||||
| [M3.5.9](M3.5.9-git-aware-references.md) | Git-aware references: lookup by code location | M | — | ⬜ |
|
||||
|
||||
## 5 — Skills · M4.x
|
||||
|
||||
| Task | Title | Size | Flags | Status |
|
||||
|---|---|---|---|---|
|
||||
| [M4.1](M4.1-skill-draft.md) | `mem skill draft` | M | — | ⬜ |
|
||||
| [M4.1](M4.1-skill-draft.md) | `mem skill draft` | M | — | 🟡 `render_skill()` in `lesson.rs`, `mem materialize` in CLI |
|
||||
| [M4.2](M4.2-derived-filter.md) | `derived: true` ingest filter | M | — | ⬜ |
|
||||
| [M4.3](M4.3-m4-gate.md) | **M4 composition gate** | M | gate | ⬜ |
|
||||
|
||||
## 5.5 — Reference corpora · M3.6.x
|
||||
|
||||
Documentation the local models are weak at — `kubectl`, `tea` — made retrievable
|
||||
as level **R**: embedded and indexed, never evidence. Ids are `M3.6.x` and stay
|
||||
`M3.6.x`; the phase sits here rather than at 4.6 because [M3.6.4](M3.6.4-reference-cycle-guard.md)
|
||||
extends M4.2's matcher instead of duplicating it, and because skills are the
|
||||
better answer to the same problem and should exist first.
|
||||
|
||||
**The load-bearing property is a negative one.** Adding a corpus must not change
|
||||
update-rate, must not change default query output, and must not put an R node in
|
||||
any provenance chain. R bypasses the recurrence structurally — `run_loop` needs a
|
||||
`Query` and a corpus has none — not by a flag. [M3.6.6](M3.6.6-m3.6-gate.md)
|
||||
asserts M1.8's numbers are *unchanged*, not merely still-passing, because
|
||||
documentation fed to the gate would lower update-rate and make M1.8 easier to
|
||||
clear while the memory got worse.
|
||||
|
||||
| Task | Title | Size | Flags | Status |
|
||||
|---|---|---|---|---|
|
||||
| [M3.6.1](M3.6.1-doc-corpus-source.md) | `DocCorpusSource` + heading chunking | M | — | ⬜ |
|
||||
| [M3.6.2](M3.6.2-level-r-storage.md) | Level R: log, index, vault, rebuild parity | M | — | ⬜ |
|
||||
| [M3.6.3](M3.6.3-mem-ref-cli.md) | `mem ref` — replace-on-change corpus management | M | — | ⬜ |
|
||||
| [M3.6.4](M3.6.4-reference-cycle-guard.md) | Reference text cannot re-enter as evidence | M | — | ⬜ |
|
||||
| [M3.6.5](M3.6.5-query-levels-and-floor.md) | Query: filter-then-recall, R opt-in, floor | M | — | ⬜ |
|
||||
| [M3.6.6](M3.6.6-m3.6-gate.md) | **M3.6 composition gate** | M | gate | ⬜ |
|
||||
|
||||
## 5.6 — Tool context · M3.7.x
|
||||
|
||||
Answers *"what do we already know about this failure, tool or task"* over HTTP.
|
||||
Consumers are `pi`, curl, or an MCP call — nothing here executes a tool, and
|
||||
nothing here serves a tool catalog, because every caller already holds its own
|
||||
MCP schemas.
|
||||
|
||||
**Three tiers, cheapest first.** An exact hash hit on a normalised failure
|
||||
signature means *this happened here before*; a symptom-vector match means
|
||||
*something similar did*; the R corpus means *nobody here has hit this, read the
|
||||
docs*. The tier is a field in the response, because those three answers must not
|
||||
arrive in the same register.
|
||||
|
||||
**The two tasks that make it work are the least obvious ones.**
|
||||
[M3.7.7](M3.7.7-signature-extraction.md) decides whether tier 1 ever fires — if
|
||||
normalisation leaves a timestamp in, the same failure never hashes twice and the
|
||||
system silently degrades to vector search.
|
||||
[M3.7.8](M3.7.8-symptom-projection.md) closes the gap between memories written as
|
||||
answers and queries that arrive as stack traces. Both fail invisibly, which is why
|
||||
[M3.7.6](M3.7.6-m3.7-gate.md) ablates them rather than trusting an end-to-end
|
||||
green.
|
||||
|
||||
Ids are `M3.7.x` and frozen. `M3.7.1` and `M3.7.2` were a tool-catalog surface,
|
||||
deleted before implementation once the consumer was settled; their ids are retired
|
||||
rather than reused.
|
||||
|
||||
| Task | Title | Size | Flags | Status |
|
||||
|---|---|---|---|---|
|
||||
| [M3.7.3](M3.7.3-skill-matching.md) | `GET /memory/skills?task=` — match a subset | M | — | ⬜ |
|
||||
| [M3.7.4](M3.7.4-context-endpoint.md) | `/memory/context` — three-tier lookup | M | — | ⬜ |
|
||||
| [M3.7.5](M3.7.5-tool-failure-learning.md) | `tool-failures` standing query | M | — | 🟡 `derive_lessons()` + `tool_of_cmd()` in `lesson.rs`, `mem resolve` in CLI |
|
||||
| [M3.7.6](M3.7.6-m3.7-gate.md) | **M3.7 composition gate** | M | gate | ⬜ |
|
||||
| [M3.7.7](M3.7.7-signature-extraction.md) | Failure signature extraction + normalisation | M | — | 🟡 `extract()` + `normalise()` in `lesson.rs` (10 unit tests passing) |
|
||||
| [M3.7.8](M3.7.8-symptom-projection.md) | Symptom projection at ingest | M | — | ⬜ |
|
||||
|
||||
## 6 — Post-training · M5.x
|
||||
|
||||
Python, separate from the Rust workspace. The boundary is the JSONL log.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|---|---|
|
||||
| Phase | M0 — Read-only spine |
|
||||
| Size | S — under 1 day |
|
||||
| Status | ⬜ Not started |
|
||||
| Status | ✅ Done |
|
||||
| Flags | — |
|
||||
| Spec | inlined below |
|
||||
| Blocks | — |
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|---|---|
|
||||
| Phase | M0 — Read-only spine |
|
||||
| Size | S — under 1 day |
|
||||
| Status | ⬜ Not started |
|
||||
| Status | ✅ Done |
|
||||
| Flags | — |
|
||||
| Spec | inlined below |
|
||||
| Blocks | M0.1 |
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|---|---|
|
||||
| Phase | M0 — Read-only spine |
|
||||
| Size | M — 1–3 days |
|
||||
| Status | ⬜ Not started |
|
||||
| Status | ✅ Done |
|
||||
| Flags | — |
|
||||
| Spec | inlined below |
|
||||
| Blocks | M0.2 |
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|---|---|
|
||||
| Phase | M0 — Read-only spine |
|
||||
| Size | M — 1–3 days |
|
||||
| Status | ⬜ Not started |
|
||||
| Status | ✅ Done |
|
||||
| Flags | — |
|
||||
| Spec | inlined below |
|
||||
| Blocks | M0.3 |
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|---|---|
|
||||
| Phase | M0 — Read-only spine |
|
||||
| Size | M — 1–3 days |
|
||||
| Status | ⬜ Not started |
|
||||
| Status | ✅ Done |
|
||||
| Flags | — |
|
||||
| Spec | inlined below |
|
||||
| Blocks | M0.3 |
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|---|---|
|
||||
| Phase | M0 — Read-only spine |
|
||||
| Size | S — under 1 day |
|
||||
| Status | ⬜ Not started |
|
||||
| Status | ✅ Done |
|
||||
| Flags | — |
|
||||
| Spec | inlined below |
|
||||
| Blocks | M0.5 |
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|---|---|
|
||||
| Phase | M0 — Read-only spine |
|
||||
| Size | S — under 1 day |
|
||||
| Status | ⬜ Not started |
|
||||
| Status | ✅ Done |
|
||||
| Flags | — |
|
||||
| Spec | inlined below |
|
||||
| Blocks | M0.4, M0.6 |
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|---|---|
|
||||
| Phase | M0 — Read-only spine |
|
||||
| Size | M — 1–3 days |
|
||||
| Status | ⬜ Not started |
|
||||
| Status | ✅ Done |
|
||||
| Flags | gate |
|
||||
| Spec | inlined below |
|
||||
| Blocks | all of M0 |
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# M1 — Gated Loop at L1 · Phase Overview
|
||||
|
||||
## What M1 produces
|
||||
|
||||
```
|
||||
crates/mem-llm/src/chat.rs ChatClient → POST /v1/qwen/chat/completions
|
||||
crates/mem-core/src/query.rs QuerySet::load() → queries/poimen.yaml
|
||||
crates/mem-core/src/prompt.rs PromptBuilder → paper Fig 10a template
|
||||
crates/mem-core/src/gate_parser.rs parse_gate_response() → GateResponse
|
||||
crates/mem-core/src/gated_loop.rs run_loop() → LoopEvent stream → RunOutcome
|
||||
crates/mem-store/src/event_log.rs LogWriter → log/<project>/<query>/<run>.jsonl
|
||||
crates/mem-cli/src/main.rs mem ingest --project P --query Q (extend existing stub)
|
||||
|
||||
tests/it_chat_client.rs 6 assertions (1 ignored, live smoke)
|
||||
tests/it_query_loader.rs 7 assertions
|
||||
tests/it_prompt.rs 7 assertions
|
||||
tests/it_gate_parser.rs 9 assertions
|
||||
tests/it_gated_loop.rs 10 assertions (scripted LLM, no network)
|
||||
tests/it_event_log.rs 8 assertions
|
||||
tests/it_ingest.rs 7 assertions (1 ignored, live smoke)
|
||||
tests/it_m1_gate.rs 7 assertions (all ignored, live gateway)
|
||||
```
|
||||
|
||||
## Existing code this phase builds on
|
||||
|
||||
| Crate | Module | Lines | What M1 uses from it |
|
||||
|---|---|---|---|
|
||||
| `mem-core` | `domain.rs` | 402 | `Chunk`, `Level`, `Sha256Hash`, `ProjectId`, `QueryId`, `RunId`, `Role`, `Record`, `MemoryNode` |
|
||||
| `mem-core` | `lesson.rs` | 871 | **Not used by M1 directly.** Already has signature extraction, normalisation, tier lookup. M3.7 extends it. |
|
||||
| `mem-chunk` | `chunker.rs` | 186 | `chunks()` — stream of `Chunk` from `RecordSource` |
|
||||
| `mem-chunk` | `token_counter.rs` | 123 | `TokenCounter` trait, `CharsOverFourCounter`, `QwenTokenCounter` |
|
||||
| `mem-chunk` | `record_source.rs` | 50 | `RecordSource` trait, `VecSource` |
|
||||
| `mem-ingest` | `pi_session.rs` | 251 | `PiSessionSource` — parse pi session JSONL |
|
||||
| `mem-ingest` | `claude_transcript.rs` | 178 | `ClaudeTranscriptSource` — parse claude transcript JSONL |
|
||||
| `mem-cli` | `main.rs` | 184 | `Commands::Ingest` stub — replace body, keep CLI struct |
|
||||
| `mem-llm` | `lib.rs` | 1 | **Empty placeholder** — replace entirely |
|
||||
| `mem-store` | `lib.rs` | 1 | **Empty placeholder** — replace entirely |
|
||||
|
||||
## Task order and blocking
|
||||
|
||||
```
|
||||
M1.1 (chat client) — no deps, start immediately
|
||||
M1.2 (query loader) — no deps, start immediately (parallel with M1.1)
|
||||
↓
|
||||
M1.3 (prompt template) — needs M1.2 for Query type
|
||||
↓
|
||||
M1.4 (gate parser) — needs M1.3 for expected output format
|
||||
↓
|
||||
M1.5 (gated loop) — needs M1.1 + M1.3 + M1.4
|
||||
↓
|
||||
M1.6 (event log) — needs M1.5 for LoopEvent types
|
||||
↓
|
||||
M1.7 (end-to-end) — needs all above
|
||||
↓
|
||||
M1.8 (gate) — needs M1.7, runs against live gateway
|
||||
```
|
||||
|
||||
**Parallel starts:** M1.1 and M1.2 can start immediately and in parallel.
|
||||
|
||||
## Key numbers
|
||||
|
||||
| Metric | Target | Source |
|
||||
|---|---|---|
|
||||
| Update-rate | < 30% | M1.8 gate, paper §4.2 |
|
||||
| Memory budget | ≤ 1024 tokens | M1.5 config, paper default |
|
||||
| Parse-failure rate | < 5% | M1.8 gate |
|
||||
| Prompt budget | < 32768 - 2048 = 30720 tokens | Gateway OLLAMA_CONTEXT_LENGTH |
|
||||
|
||||
## Test convention
|
||||
|
||||
All integration tests live in workspace root `tests/` directory (matching `it_chunking.rs`, `it_pi_source.rs`, etc. from M0). Test command is always:
|
||||
```bash
|
||||
cargo test --test <test_file_name>
|
||||
```
|
||||
|
||||
Not `cargo test -p <crate>` — that only finds tests inside the crate's own `tests/` directory, which this project doesn't use.
|
||||
@@ -4,7 +4,7 @@
|
||||
|---|---|
|
||||
| Phase | M1 — Gated loop at L1 |
|
||||
| Size | M — 1–3 days |
|
||||
| Status | ⬜ Not started |
|
||||
| Status | ✅ Done |
|
||||
| Flags | — |
|
||||
| Spec | inlined below |
|
||||
| Blocks | M0.1 |
|
||||
@@ -14,6 +14,69 @@
|
||||
Talk to the homelab gateway, with the two non-obvious details that cost a day to
|
||||
find already baked in.
|
||||
|
||||
## Files
|
||||
|
||||
| Action | Path |
|
||||
|---|---|
|
||||
| Create | `crates/mem-llm/src/chat.rs` — `ChatClient`, `Completion`, `Usage` |
|
||||
| Replace | `crates/mem-llm/src/lib.rs` — replace `pub mod placeholder {}` with `pub mod chat;` + re-exports |
|
||||
| Create | `tests/it_chat_client.rs` — integration tests (workspace root, matches existing convention) |
|
||||
| Modify | `Cargo.toml` root — add `wiremock = "0.6"` to `[dev-dependencies]` |
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Crate | Where | Already present? |
|
||||
|---|---|---|
|
||||
| `reqwest` (json feature) | `crates/mem-llm/Cargo.toml` | ✅ yes |
|
||||
| `serde`, `serde_json` | `crates/mem-llm/Cargo.toml` | ✅ yes |
|
||||
| `tokio` | `crates/mem-llm/Cargo.toml` | ✅ yes |
|
||||
| `anyhow`, `thiserror` | `crates/mem-llm/Cargo.toml` | ✅ yes |
|
||||
| `wiremock = "0.6"` | root `Cargo.toml` `[dev-dependencies]` | ❌ add |
|
||||
|
||||
## Existing code
|
||||
|
||||
- `crates/mem-llm/src/lib.rs` is an empty placeholder — replace entirely
|
||||
- No existing HTTP client code to reuse; build from scratch
|
||||
- `crates/mem-core/src/lesson.rs` has an unrelated events JSONL writer — ignore it here
|
||||
|
||||
## API shape
|
||||
|
||||
```http
|
||||
POST https://api.riotpiao.com/v1/qwen/chat/completions
|
||||
|
||||
Headers:
|
||||
apikey: <value of MEM_API_KEY env var>
|
||||
Content-Type: application/json
|
||||
|
||||
Body (note: NO "tools" key — not even an empty array):
|
||||
{
|
||||
"model": "qwen2.5:3b-instruct",
|
||||
"messages": [
|
||||
{"role": "system", "content": "<system prompt>"},
|
||||
{"role": "user", "content": "<user prompt>"}
|
||||
],
|
||||
"max_tokens": 2048
|
||||
}
|
||||
|
||||
Response 200:
|
||||
{
|
||||
"choices": [
|
||||
{"message": {"role": "assistant", "content": "<model output>"}}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 1234,
|
||||
"completion_tokens": 567,
|
||||
"total_tokens": 1801
|
||||
}
|
||||
}
|
||||
|
||||
Response 401 (wrong auth header):
|
||||
{"message": "Unauthorized"}
|
||||
|
||||
Response 400 (body too large or malformed):
|
||||
{"error": {"message": "[] is too short - 'messages'"}}
|
||||
```
|
||||
|
||||
## Facts (inlined — no spec read needed)
|
||||
|
||||
```
|
||||
@@ -84,7 +147,7 @@ live gateway.
|
||||
6. `a6_live_smoke` — `#[ignore]`; real gateway, `qwen2.5:3b-instruct`, prompt
|
||||
"reply with exactly: pong", assert the text contains `pong`.
|
||||
|
||||
**Command:** `cargo test -p mem-llm chat_client` (add `-- --ignored` for a6)
|
||||
**Command:** `cargo test --test it_chat_client` (add `-- --ignored` for a6)
|
||||
|
||||
**False pass:**
|
||||
- Testing only against the mock. The mock accepts whatever header you send it;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|---|---|
|
||||
| Phase | M1 — Gated loop at L1 |
|
||||
| Size | M — 1–3 days |
|
||||
| Status | ⬜ Not started |
|
||||
| Status | ✅ Done |
|
||||
| Flags | — |
|
||||
| Spec | inlined below |
|
||||
| Blocks | M0.2 |
|
||||
@@ -14,6 +14,33 @@
|
||||
Load the standing questions that give the update gate its referent, and fail at
|
||||
load rather than mid-run when one is wrong.
|
||||
|
||||
## Files
|
||||
|
||||
| Action | Path |
|
||||
|---|---|
|
||||
| Create | `crates/mem-core/src/query.rs` — `QuerySet`, `Query`, `SynthesisQuery`, `QueryLoadError` |
|
||||
| Modify | `crates/mem-core/src/lib.rs` — add `pub mod query;` and re-exports |
|
||||
| Create | `queries/poimen.yaml` — first real standing query file |
|
||||
| Create | `tests/it_query_loader.rs` — integration tests (workspace root) |
|
||||
| Create | `fixtures/query-valid.yaml` — test fixture (valid) |
|
||||
| Create | `fixtures/query-empty-question.yaml` — test fixture (empty question) |
|
||||
| Create | `fixtures/query-duplicate-id.yaml` — test fixture (duplicate id) |
|
||||
| Create | `fixtures/query-bad-charset.yaml` — test fixture (invalid id chars) |
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Crate | Where | Already present? |
|
||||
|---|---|---|
|
||||
| `serde_yaml` | workspace deps | ✅ yes (in workspace `[workspace.dependencies]`) |
|
||||
| `serde` | `crates/mem-core/Cargo.toml` | ✅ yes |
|
||||
| `regex` | `crates/mem-core/Cargo.toml` | ❌ add (for `[a-z0-9-]+` validation) or hand-roll |
|
||||
|
||||
## Existing code to reuse
|
||||
|
||||
- `ProjectId`, `QueryId` from `crates/mem-core/src/domain.rs` — **use these newtypes**, don't create new ones
|
||||
- `serde_yaml` already used by `mem-ingest` — same pattern
|
||||
- Validation pattern: `QueryId::new()` already rejects empty strings; extend with charset validation
|
||||
|
||||
## Facts (inlined — no spec read needed)
|
||||
|
||||
```yaml
|
||||
@@ -83,7 +110,7 @@ the log directory; renaming it orphans all three.
|
||||
`true` truncates every extraction and looks like a model quality problem.
|
||||
7. `a7_missing_question_field` — absent key behaves as empty, same error.
|
||||
|
||||
**Command:** `cargo test -p mem-core query_loader`
|
||||
**Command:** `cargo test --test it_query_loader`
|
||||
|
||||
**False pass:**
|
||||
- Testing only the happy path. Every assertion except 1 and 5 is a rejection
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|---|---|
|
||||
| Phase | M1 — Gated loop at L1 |
|
||||
| Size | M — 1–3 days |
|
||||
| Status | ⬜ Not started |
|
||||
| Status | ✅ Done |
|
||||
| Flags | — |
|
||||
| Spec | inlined below |
|
||||
| Blocks | M1.2 |
|
||||
@@ -14,6 +14,30 @@
|
||||
Assemble the memory-agent prompt exactly as the paper specifies, because the
|
||||
model's ability to emit parseable gates depends on the format it was aligned to.
|
||||
|
||||
## Files
|
||||
|
||||
| Action | Path |
|
||||
|---|---|
|
||||
| Create | `crates/mem-core/src/prompt.rs` — `PromptBuilder` struct |
|
||||
| Modify | `crates/mem-core/src/lib.rs` — add `pub mod prompt;` |
|
||||
| Create | `templates/gru-mem.txt` — the prompt template (verbatim from paper Fig 10a) |
|
||||
| Create | `fixtures/expected/prompt-t1.txt` — golden file for turn 1 |
|
||||
| Create | `fixtures/expected/prompt-tn.txt` — golden file for turn N |
|
||||
| Create | `tests/it_prompt.rs` — integration tests (workspace root) |
|
||||
|
||||
## Dependencies
|
||||
|
||||
**None new.** No template engine — the prompt has 3 substitutions (`{prompt}`,
|
||||
`{memory}`, `{chunk}`). Use `str::replace()` or `format!()`. Adding `tera` for
|
||||
3 variables is overengineering.
|
||||
|
||||
## Existing code to reuse
|
||||
|
||||
- `Chunk` from `domain.rs` — render its `records` vec
|
||||
- `Role` from `domain.rs` — map to `[User]`, `[Assistant]`, `[ToolResult]`, `[System]` labels
|
||||
- `Query` from `query.rs` (M1.2) — read `query.question` for the `{prompt}` substitution
|
||||
- `TokenCounter` from `mem-chunk` — check assembled prompt fits budget
|
||||
|
||||
## Facts (inlined — no spec read needed)
|
||||
|
||||
Paper Figure 10a, reproduced verbatim — this is the contract, not a starting
|
||||
@@ -98,7 +122,7 @@ change to the contract.
|
||||
7. `a7_budget_headroom` — for the real fixture corpus, assert every assembled
|
||||
prompt is under 32768 minus 2048.
|
||||
|
||||
**Command:** `cargo test -p mem-core prompt`
|
||||
**Command:** `cargo test --test it_prompt`
|
||||
|
||||
**False pass:**
|
||||
- Asserting the prompt "contains" the question. A template that dropped the
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|---|---|
|
||||
| Phase | M1 — Gated loop at L1 |
|
||||
| Size | M — 1–3 days |
|
||||
| Status | ⬜ Not started |
|
||||
| Status | ✅ Done |
|
||||
| Flags | — |
|
||||
| Spec | inlined below |
|
||||
| Blocks | M1.3 |
|
||||
@@ -14,6 +14,47 @@
|
||||
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:
|
||||
@@ -88,7 +129,7 @@ cases. Capture real ones with `MEM_LLM_RECORD` from M1.1.
|
||||
silently-defaulted `GateResponse`.
|
||||
9. `a9_real_responses` — every recorded real response parses.
|
||||
|
||||
**Command:** `cargo test -p mem-core gate_parser`
|
||||
**Command:** `cargo test --test it_gate_parser`
|
||||
|
||||
**False pass:**
|
||||
- A regex that finds the first `<check>` and stops. It passes 1–2 and silently
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|---|---|
|
||||
| Phase | M1 — Gated loop at L1 |
|
||||
| Size | L — 3+ days |
|
||||
| Status | ⬜ Not started |
|
||||
| Status | ✅ Done |
|
||||
| Flags | — |
|
||||
| Spec | inlined below |
|
||||
| Blocks | M1.4 |
|
||||
@@ -14,6 +14,44 @@
|
||||
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:
|
||||
@@ -97,7 +135,7 @@ the whole loop runs with no network and fully determined gate sequences.
|
||||
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`
|
||||
**Command:** `cargo test --test it_gated_loop`
|
||||
|
||||
**False pass:**
|
||||
- Testing with a fake that always returns `yes`. Every assertion about the retain
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|---|---|
|
||||
| Phase | M1 — Gated loop at L1 |
|
||||
| Size | M — 1–3 days |
|
||||
| Status | ⬜ Not started |
|
||||
| Status | ✅ Done |
|
||||
| Flags | — |
|
||||
| Spec | inlined below |
|
||||
| Blocks | M1.5 |
|
||||
@@ -14,6 +14,43 @@
|
||||
Write the authoritative record — the one artifact everything else is derived
|
||||
from, and the one that must survive a crash mid-run.
|
||||
|
||||
## Files
|
||||
|
||||
| Action | Path |
|
||||
|---|---|
|
||||
| Create | `crates/mem-store/src/event_log.rs` — `LogWriter`, `LogReader`, `RunStatus` |
|
||||
| Replace | `crates/mem-store/src/lib.rs` — replace `pub mod placeholder {}` with `pub mod event_log;` + re-exports |
|
||||
| Create | `tests/it_event_log.rs` — integration tests (workspace root, 8 assertions) |
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Crate | Where | Already present? |
|
||||
|---|---|---|
|
||||
| `ulid` or `rusty_ulid` | `crates/mem-store/Cargo.toml` | ❌ add — for sortable run IDs |
|
||||
| `tokio` (fs feature) | `crates/mem-store/Cargo.toml` | ✅ yes |
|
||||
| `serde`, `serde_json` | `crates/mem-store/Cargo.toml` | ✅ yes |
|
||||
|
||||
## Existing code to reuse
|
||||
|
||||
- `LoopEvent` from `gated_loop.rs` (M1.5) — the events to serialize
|
||||
- `Level` from `domain.rs` — carried in every record
|
||||
- Pattern reference: `lessons_cmd.rs` has a JSONL writer for `Event` types (`events.jsonl`).
|
||||
Same concept but **different event schema** and **different storage location**:
|
||||
- `lessons_cmd.rs` writes `~/.mem/events.jsonl` (command execution events)
|
||||
- M1.6 writes `log/<project>/<query>/<run-id>.jsonl` (gate decision events)
|
||||
- Do not unify them. They serve different purposes.
|
||||
|
||||
## Output path convention
|
||||
|
||||
```
|
||||
log/
|
||||
poimen/
|
||||
tool-failures/
|
||||
01HXYZ....jsonl ← ULID, lexicographically sortable by time
|
||||
architecture-decisions/
|
||||
01HXYZ....jsonl
|
||||
```
|
||||
|
||||
## Facts (inlined — no spec read needed)
|
||||
|
||||
Path: `log/<project>/<query-id>/<run-id>.jsonl`. Append-only, one object per line.
|
||||
@@ -80,7 +117,7 @@ fixture.
|
||||
8. `a8_run_id_sorts_by_time` — three runs, assert lexicographic order equals
|
||||
chronological order.
|
||||
|
||||
**Command:** `cargo test -p mem-store event_log`
|
||||
**Command:** `cargo test --test it_event_log`
|
||||
|
||||
**False pass:**
|
||||
- Asserting the file parses. A writer that omits `evidence` events entirely
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|---|---|
|
||||
| Phase | M1 — Gated loop at L1 |
|
||||
| Size | M — 1–3 days |
|
||||
| Status | ⬜ Not started |
|
||||
| Status | ✅ Done |
|
||||
| Flags | — |
|
||||
| Spec | inlined below |
|
||||
| Blocks | M1.6 |
|
||||
@@ -14,6 +14,56 @@
|
||||
One command that reads a real project and produces a real log — and reports the
|
||||
number that says whether the gate works.
|
||||
|
||||
## Files
|
||||
|
||||
| Action | Path |
|
||||
|---|---|
|
||||
| Modify | `crates/mem-cli/src/main.rs` — extend `Commands::Ingest` with `--query` and `--resume` flags; replace stub `cmd_ingest()` body with real pipeline |
|
||||
| Create | `tests/it_ingest.rs` — integration tests (workspace root, 7 assertions) |
|
||||
|
||||
## Dependencies
|
||||
|
||||
**None new.** All crates already depend on what they need. This task wires existing
|
||||
pieces together.
|
||||
|
||||
## Existing code to reuse
|
||||
|
||||
- `cmd_ingest()` in `main.rs` — **replace the stub body**, keep the CLI struct
|
||||
- `PiSessionSource` from `mem-ingest/src/pi_session.rs` — already works (M0.5)
|
||||
- `ClaudeTranscriptSource` from `mem-ingest/src/claude_transcript.rs` — already works (M0.6)
|
||||
- `chunks()` from `mem-chunk/src/chunker.rs` — already works (M0.3)
|
||||
- `ChunkPolicy` from `mem-chunk/src/chunk_policy.rs` — already works
|
||||
- `QuerySet::load()` from `mem-core/src/query.rs` — from M1.2
|
||||
- `run_loop()` from `mem-core/src/gated_loop.rs` — from M1.5
|
||||
- `LogWriter` from `mem-store/src/event_log.rs` — from M1.6
|
||||
- `ChatClient` from `mem-llm/src/chat.rs` — from M1.1
|
||||
|
||||
## Wiring diagram
|
||||
|
||||
```
|
||||
CLI: mem ingest --project poimen --query tool-failures
|
||||
│
|
||||
├─ QuerySet::load("queries/poimen.yaml") ← M1.2
|
||||
│ └─ query = set.by_id("tool-failures")
|
||||
│
|
||||
├─ PiSessionSource::new(session_files) ← M0.5 (existing)
|
||||
│ └─ source.records() → Stream<Record>
|
||||
│
|
||||
├─ chunks(source, policy) ← M0.3 (existing)
|
||||
│ └─ Stream<Chunk>
|
||||
│
|
||||
├─ ChatClient::new(base_url, api_key, model) ← M1.1
|
||||
│
|
||||
├─ run_loop(L1, query, chunks, client, config) ← M1.5
|
||||
│ └─ RunOutcome { events, chunks_seen, chunks_used, ... }
|
||||
│
|
||||
├─ LogWriter::open(project, query, run_id) ← M1.6
|
||||
│ └─ write events to log/poimen/tool-failures/<ulid>.jsonl
|
||||
│
|
||||
└─ Print summary:
|
||||
chunks 412 used 17 update-rate 4.1% memory 142tok elapsed 6m12s
|
||||
```
|
||||
|
||||
## Facts (inlined — no spec read needed)
|
||||
|
||||
```
|
||||
@@ -79,7 +129,7 @@ chunk costs a model call, so `--resume` is not a nicety.
|
||||
7. `a7_live_smoke` — `#[ignore]`; real gateway, `--limit 20` on a real project;
|
||||
assert `run_end` and **print** the update-rate for a human to read.
|
||||
|
||||
**Command:** `cargo test -p mem-cli ingest` (add `-- --ignored` for a7)
|
||||
**Command:** `cargo test --test it_ingest` (add `-- --ignored` for a7)
|
||||
|
||||
**False pass:**
|
||||
- Asserting only that the command exits 0. A run whose gate always answers `no`
|
||||
|
||||
+21
-2
@@ -4,7 +4,7 @@
|
||||
|---|---|
|
||||
| Phase | M1 — Gated loop at L1 |
|
||||
| Size | M — 1–3 days |
|
||||
| Status | ⬜ Not started |
|
||||
| Status | ✅ Done |
|
||||
| Flags | gate |
|
||||
| Spec | inlined below |
|
||||
| Blocks | all of M1 |
|
||||
@@ -14,6 +14,25 @@
|
||||
Answer the only question that matters at this stage: **did we build a gate, or an
|
||||
expensive summarizer?**
|
||||
|
||||
## Files
|
||||
|
||||
| Action | Path |
|
||||
|---|---|
|
||||
| Create | `tests/it_m1_gate.rs` — integration tests (workspace root, all `#[ignore]`, 7 assertions) |
|
||||
| Create | `fixtures/expected/m1-gate.txt` — committed expectation file (summary table) |
|
||||
|
||||
## Dependencies
|
||||
|
||||
**None new.** This task runs existing code against the live gateway.
|
||||
|
||||
## Existing code to reuse
|
||||
|
||||
The entire M1 pipeline:
|
||||
- `QuerySet::load()` (M1.2), `ChatClient` (M1.1), `run_loop()` (M1.5), `LogWriter` (M1.6)
|
||||
- `PiSessionSource` / `ClaudeTranscriptSource` (M0.5/M0.6) — existing
|
||||
- `chunks()` (M0.3) — existing
|
||||
- `LogReader::stats()` (M1.6) — to recompute update-rate independently
|
||||
|
||||
## Facts (inlined — no spec read needed)
|
||||
|
||||
This gate runs against the **live gateway on a real project** and asserts
|
||||
@@ -75,7 +94,7 @@ per-push.
|
||||
7. `a7_judge_audit` — sample 20 decisions, ask the 32B model, print agreement.
|
||||
Advisory; does not fail the gate.
|
||||
|
||||
**Command:** `cargo test --workspace m1_gate -- --ignored --nocapture`
|
||||
**Command:** `cargo test --test it_m1_gate -- --ignored --nocapture`
|
||||
|
||||
**False pass:**
|
||||
- Running the gate on a tiny `--limit`. Update-rate on the first 20 chunks is
|
||||
|
||||
@@ -19,15 +19,14 @@ malformed graph impossible rather than merely unlikely.
|
||||
```sql
|
||||
CREATE TABLE memory_node (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
level TEXT NOT NULL CHECK (level IN ('L0','L1','L2')),
|
||||
level TEXT NOT NULL CHECK (level IN ('L0','L1','L2','R')),
|
||||
project TEXT NOT NULL,
|
||||
query_id TEXT, -- NULL at L2
|
||||
query_id TEXT, -- NULL at L2 and R
|
||||
run_id TEXT NOT NULL,
|
||||
t INT NOT NULL,
|
||||
source TEXT, -- set at L0
|
||||
source TEXT, -- set at L0; source URI at R
|
||||
text TEXT NOT NULL,
|
||||
sha256 TEXT NOT NULL UNIQUE, -- content identity, from M0.2
|
||||
embedding vector(768) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE memory_edge (
|
||||
@@ -35,10 +34,53 @@ CREATE TABLE memory_edge (
|
||||
parent_sha TEXT NOT NULL REFERENCES memory_node(sha256) ON DELETE CASCADE,
|
||||
PRIMARY KEY (child_sha, parent_sha)
|
||||
);
|
||||
CREATE INDEX ON memory_node USING hnsw (embedding vector_cosine_ops);
|
||||
CREATE INDEX ON memory_node (project, level);
|
||||
|
||||
-- Vectors live outside the node: one node carries several, and a symptom
|
||||
-- projection (M3.7.8) is what makes an answer findable from an error message.
|
||||
CREATE TABLE memory_vector (
|
||||
node_sha TEXT NOT NULL REFERENCES memory_node(sha256) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('text','symptom')),
|
||||
embedding vector(768) NOT NULL,
|
||||
PRIMARY KEY (node_sha, kind)
|
||||
);
|
||||
-- Partial index per kind. One index over mixed kinds forces post-filtering,
|
||||
-- which starves recall exactly the way M3.6.5 describes.
|
||||
CREATE INDEX ON memory_vector USING hnsw (embedding vector_cosine_ops) WHERE kind = 'text';
|
||||
CREATE INDEX ON memory_vector USING hnsw (embedding vector_cosine_ops) WHERE kind = 'symptom';
|
||||
|
||||
-- Exact-match tier. Failures repeat verbatim; prose does not.
|
||||
CREATE TABLE failure_signature (
|
||||
sig_sha TEXT PRIMARY KEY, -- hash of the NORMALISED signature (M3.7.7)
|
||||
node_sha TEXT NOT NULL REFERENCES memory_node(sha256) ON DELETE CASCADE,
|
||||
tool TEXT NOT NULL, -- 'github-actions' | 'kubectl' | 'npm'
|
||||
raw TEXT NOT NULL, -- pre-normalisation, for display
|
||||
seen_count INT NOT NULL DEFAULT 1,
|
||||
last_seen TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE INDEX ON failure_signature (tool);
|
||||
|
||||
-- A lesson about Kong is actively harmful now that Kong is retired.
|
||||
CREATE TABLE memory_supersede (
|
||||
old_sha TEXT NOT NULL REFERENCES memory_node(sha256) ON DELETE CASCADE,
|
||||
new_sha TEXT NOT NULL REFERENCES memory_node(sha256) ON DELETE CASCADE,
|
||||
reason TEXT,
|
||||
PRIMARY KEY (old_sha, new_sha)
|
||||
);
|
||||
```
|
||||
|
||||
**`embedding` is deliberately not a column on `memory_node`.** A node needs more
|
||||
than one vector: the memory text as written, and a generated *symptom* projection
|
||||
describing the errors it would explain. An L1 reads like an answer and a query
|
||||
reads like a stack trace, and cosine between those two registers is mediocre —
|
||||
the second vector is what closes that gap. One column cannot hold both, and
|
||||
bolting on `embedding_2` later is worse than a junction table now.
|
||||
|
||||
**`seen_count` and `last_seen` are mutable and that does not break the authority
|
||||
rule.** Occurrences are append-only records in the JSONL log; these two fields are
|
||||
a fold over them, recomputed by `mem rebuild --from-log` like every other
|
||||
projected value.
|
||||
|
||||
`sha256 UNIQUE` is what makes rebuild idempotent — re-inserting identical content
|
||||
is a conflict to ignore, not a duplicate row. It is also why the hash must exclude
|
||||
run ids and timestamps (M0.2).
|
||||
@@ -47,8 +89,13 @@ run ids and timestamps (M0.2).
|
||||
dangling edges. Rebuild drops everything anyway, but a partial cleanup should not
|
||||
be able to corrupt the graph.
|
||||
|
||||
`query_id` is NULL at L2 by design — L2 spans queries. Enforce it:
|
||||
`CHECK ((level = 'L2') = (query_id IS NULL))`.
|
||||
`query_id` is NULL at L2 **and at R** — L2 spans queries, R answers none. Enforce
|
||||
it: `CHECK ((level IN ('L2','R')) = (query_id IS NULL))`.
|
||||
|
||||
The `'R'` level ships here rather than arriving as an `ALTER` from M3.6. Nothing
|
||||
is built yet, so widening a constraint that has never existed wrong is free, and
|
||||
a migration that exists only because an earlier migration was knowingly
|
||||
incomplete is debt taken on for no reason.
|
||||
|
||||
Cosine distance, not L2: these are normalised text embeddings and cosine is what
|
||||
the model was trained for. `vector_cosine_ops` must match the operator the query
|
||||
@@ -69,8 +116,9 @@ uses (`<=>`), or the index is silently ignored and every query is a seq scan.
|
||||
|
||||
- Migrations apply to a clean database and are idempotent.
|
||||
- Inserting a duplicate `sha256` conflicts rather than duplicating.
|
||||
- An `L2` row with a non-null `query_id` is rejected by the CHECK.
|
||||
- The HNSW index is used by a cosine-distance query.
|
||||
- An `L2` or `R` row with a non-null `query_id` is rejected by the CHECK.
|
||||
- Both partial HNSW indexes are used by a kind-filtered cosine query.
|
||||
- A node can carry a `text` and a `symptom` vector simultaneously.
|
||||
|
||||
## Verify
|
||||
|
||||
@@ -81,12 +129,21 @@ image tag as production, `ghcr.io/cloudnative-pg/postgresql:16.2`.
|
||||
1. `a1_migrate_clean` — apply to an empty database, assert both tables exist.
|
||||
2. `a2_migrate_idempotent` — apply twice, assert no error.
|
||||
3. `a3_sha_unique` — insert the same sha twice, assert a unique violation.
|
||||
4. `a4_level_check` — `level='L3'` rejected; `level='L2'` with a `query_id`
|
||||
rejected; `level='L1'` without one rejected.
|
||||
4. `a4_level_check` — `level='L3'` rejected; `level='L2'` and `level='R'` with a
|
||||
`query_id` rejected; `level='L1'` without one rejected.
|
||||
5. `a5_edge_fk` — an edge referencing a missing sha is rejected.
|
||||
6. `a6_cascade` — delete a node, assert its edges are gone.
|
||||
7. `a7_hnsw_is_used` — `EXPLAIN` a `ORDER BY embedding <=> $1 LIMIT 10` query and
|
||||
assert the plan contains `Index Scan` on the HNSW index, not `Seq Scan`.
|
||||
6. `a6_cascade` — delete a node, assert its edges, vectors and signatures are
|
||||
gone.
|
||||
7. `a7_hnsw_is_used` — `EXPLAIN` a `WHERE kind='text' ORDER BY embedding <=> $1
|
||||
LIMIT 10` query; assert the plan contains an `Index Scan` on the **partial**
|
||||
index, not `Seq Scan` and not a filter applied above a full-index scan.
|
||||
8. `a8_symptom_index_separate` — same for `kind='symptom'`; assert the plan names
|
||||
the other index, proving both were created and are distinguishable.
|
||||
9. `a9_two_vectors_per_node` — insert both kinds for one node; assert both
|
||||
persist and the composite primary key rejects a third of the same kind.
|
||||
10. `a10_signature_unique` — inserting the same `sig_sha` twice conflicts.
|
||||
11. `a11_supersede_pair` — a supersede row survives, and deleting either endpoint
|
||||
cascades it away.
|
||||
|
||||
**Command:** `cargo test -p mem-store schema`
|
||||
|
||||
|
||||
@@ -17,10 +17,12 @@ time and produces the same rows.
|
||||
## Facts (inlined — no spec read needed)
|
||||
|
||||
```rust
|
||||
async fn upsert_node(&self, node: &MemoryNode, embedding: &[f32]) -> Result<()>;
|
||||
async fn upsert_node(&self, node: &MemoryNode) -> Result<()>;
|
||||
async fn upsert_vector(&self, sha: &Sha256Hash, kind: VectorKind, embedding: &[f32]) -> Result<()>;
|
||||
async fn insert_edges(&self, child: &Sha256Hash, parents: &[Sha256Hash]) -> Result<()>;
|
||||
async fn search(&self, q: &[f32], levels: &[Level], project: &ProjectId, k: usize)
|
||||
-> Result<Vec<ScoredNode>>;
|
||||
async fn search(&self, q: &[f32], kind: VectorKind, levels: &[Level],
|
||||
project: Scope, k: usize) -> Result<Vec<ScoredNode>>;
|
||||
async fn lookup_signature(&self, sig_sha: &str) -> Result<Option<SignatureHit>>;
|
||||
async fn parents_of(&self, sha: &Sha256Hash) -> Result<Vec<MemoryNode>>;
|
||||
async fn clear_project(&self, project: &ProjectId) -> Result<()>;
|
||||
```
|
||||
@@ -32,6 +34,23 @@ rows. Same for edges on the composite key.
|
||||
`search` orders by `embedding <=> $1` — cosine distance, matching the
|
||||
`vector_cosine_ops` index. Any other operator silently drops to a seq scan.
|
||||
|
||||
**`kind` must be a literal predicate in the SQL, not a bind parameter, and not a
|
||||
filter applied to results.** The indexes are partial (`WHERE kind = 'text'`), and
|
||||
the planner only uses a partial index when the query's predicate provably matches
|
||||
it. A `WHERE kind = $2` defeats that and silently degrades to a scan over every
|
||||
vector of both kinds — the same failure mode as the wrong opclass, and just as
|
||||
invisible.
|
||||
|
||||
**`Scope` is not a `ProjectId`.** Tool-failure lookups federate across projects
|
||||
because an `ERESOLVE` lesson is not project-specific, while ordinary standing-query
|
||||
memories stay scoped. `Scope::Project(id)` filters; `Scope::AllProjects` does not
|
||||
and lets project relevance act as a rank boost later instead of a hard filter.
|
||||
|
||||
`lookup_signature` is the exact-match tier: a primary-key hit on
|
||||
`failure_signature`, no vector involved. It is the cheapest and highest-precision
|
||||
answer the store can give, so it belongs in the repository rather than being
|
||||
assembled from a `search` call by a caller who does not know it exists.
|
||||
|
||||
Edges are inserted **after** both endpoints exist, or the foreign key rejects
|
||||
them. Rebuild therefore has two passes: all nodes, then all edges. This is not an
|
||||
optimisation; a single-pass insert fails on the first forward reference.
|
||||
@@ -44,12 +63,23 @@ rebuild — batch across nodes, not per node.
|
||||
1. `PgRepo::connect(url)` with a pool; run migrations on connect.
|
||||
2. Implement the five methods above.
|
||||
3. `upsert_many(nodes)` batching embedding calls at 32 and inserting with a
|
||||
multi-row statement.
|
||||
4. Two-pass write: nodes, then edges.
|
||||
multi-row statement. Batch across *both* vector kinds — a node with a symptom
|
||||
projection contributes two texts to the same batch, not two batches.
|
||||
4. Three-pass write: nodes, then vectors and signatures, then edges. Vectors and
|
||||
signatures carry foreign keys to nodes, so they cannot precede them, and edges
|
||||
still need both endpoints present.
|
||||
5. Separate query builders per `kind` so the literal predicate is guaranteed at
|
||||
compile time rather than by convention.
|
||||
5. `clear_project` deletes nodes for one project; edges cascade.
|
||||
6. Return `ScoredNode { node, distance }` — keep the raw distance, do not convert
|
||||
to a similarity score here. The reranker (M3.2) wants the ordering, and a
|
||||
lossy conversion hides ties.
|
||||
6. Return `ScoredNode { node, distance, matched_kind }` — keep the raw distance,
|
||||
do not convert to a similarity score here. The reranker (M3.2) wants the
|
||||
ordering, and a lossy conversion hides ties. `matched_kind` tells the caller
|
||||
whether the hit came from the memory text or its symptom projection, which is
|
||||
the difference between "this is about your topic" and "this explains your
|
||||
error".
|
||||
7. Exclude superseded nodes by default: `LEFT JOIN memory_supersede` on
|
||||
`old_sha`, filter where the join is null. An `include_superseded` flag exists
|
||||
for audit, off everywhere else.
|
||||
|
||||
## Acceptance
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|---|---|
|
||||
| Phase | M3 — L2 synthesis and retrieval |
|
||||
| Size | M — 1–3 days |
|
||||
| Status | ⬜ Not started |
|
||||
| Status | ✅ Done |
|
||||
| Flags | — |
|
||||
| Spec | inlined below |
|
||||
| Blocks | M1.5 |
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|---|---|
|
||||
| Phase | M3.5 — Distributed API Layer |
|
||||
| Size | M — 1–3 days |
|
||||
| Status | ⬜ Not started |
|
||||
| Status | ✅ Done |
|
||||
| Flags | — |
|
||||
| Spec | inlined below |
|
||||
| Blocks | M3.5.2, M3.5.3, M3.5.5, M3.5.6 |
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
## Goal
|
||||
|
||||
Async ingest endpoint that demultiplexes gated-loop submissions from CLI and agents. Idempotent by batch content hash (`ingest_id`). Prevent duplicate L0 evidence in the log.
|
||||
Async ingest endpoint that demultiplexes gated-loop submissions from CLI and agents. Idempotent by batch content hash (`ingest_id`). Prevent duplicate L0 evidence in the log. Enrich records with git context (file, commit, blame) if repo.git available.
|
||||
|
||||
## Design
|
||||
|
||||
@@ -28,10 +28,17 @@ Content-Type: application/json
|
||||
{"role":"assistant","text":"...","timestamp":"2026-08-20T...","source_position":0},
|
||||
...
|
||||
],
|
||||
"ingest_id": "sha256(all_record_texts)"
|
||||
"ingest_id": "sha256(all_record_texts)",
|
||||
"git_repo_path": "/path/to/repo/.git",
|
||||
"git_head": "abc123def789"
|
||||
}
|
||||
```
|
||||
|
||||
**Git enrichment (optional):** If `git_repo_path` and `git_head` provided:
|
||||
- Walk repo blame for timestamps matching record timestamps
|
||||
- Correlate evidence text with recent commits touching files
|
||||
- Populate `git_context` on each L0 node (file, line, commit, author)
|
||||
|
||||
**Response (accepted):**
|
||||
```
|
||||
HTTP 202 Accepted
|
||||
@@ -74,7 +81,11 @@ GET /memory/ingest/ingest-<job-id>
|
||||
- Compute `estimated_wait_seconds` based on current queue depth and avg chunk processing latency (5000 tokens @ 812ms gate latency ≈ 4.2s per chunk).
|
||||
3. Background task (tokio::spawn):
|
||||
- Dequeue from project queue (FIFO per project)
|
||||
- Call the M1.7 `mem::ingest()` function with records
|
||||
- **Git enrichment (if git_repo_path provided):**
|
||||
- Open repo.git with `git2::Repository`
|
||||
- For each record, find blame line by timestamp + closest file match (via commit log)
|
||||
- Populate `git_context: {file, line, commit_sha, commit_msg, author, author_date}`
|
||||
- Call the M1.7 `mem::ingest()` function with enriched records
|
||||
- Update status to `completed` with `chunks_seen` and `chunks_used` from the log
|
||||
- On error, update status to `failed` with error message
|
||||
4. `GET /memory/ingest/<job_id>` handler:
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
# M3.5.9 — Git-aware memory references: lookup by code location
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Phase | M3.5 — Distributed API Layer |
|
||||
| Size | M — 1–3 days |
|
||||
| Status | ⬜ Not started |
|
||||
| Flags | — |
|
||||
| Spec | inlined below |
|
||||
| Blocks | — |
|
||||
| Depends | M3.5.2 (git enrichment in ingest), M3.5.3 (query endpoint) |
|
||||
|
||||
## Goal
|
||||
|
||||
Enable agents to find and cite memory entries by code location (file:line, commit, author). Unifies memory log with git history. Agents reference: `"Per src/kong/buffer.rs:42 (commit abc123)..."` → lookup via git blame, return L0 evidence + L1 memory.
|
||||
|
||||
## Design
|
||||
|
||||
**New database columns** (extend `memory_node` from M2.3):
|
||||
```sql
|
||||
ALTER TABLE memory_node ADD COLUMN git_context JSONB;
|
||||
-- {file, line, commit_sha, commit_msg, author, author_date}
|
||||
|
||||
-- Index for git-based lookup
|
||||
CREATE INDEX ON memory_node USING GIN (git_context);
|
||||
```
|
||||
|
||||
**Three lookup modes:**
|
||||
|
||||
1. **By git location (file:line):**
|
||||
```
|
||||
POST /memory/nodes/by-git
|
||||
{
|
||||
"repo": "github.com/org/poimen",
|
||||
"file": "src/kong/buffer.rs",
|
||||
"line": 42,
|
||||
"project": "poimen"
|
||||
}
|
||||
→ 200 {
|
||||
"nodes": [
|
||||
{
|
||||
"sha256": "...",
|
||||
"level": "L0",
|
||||
"text": "Kong body buffer raised to 16MB...",
|
||||
"git_context": {commit_sha, commit_msg, author},
|
||||
"created_at": "2026-08-20T..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
2. **By commit (evidence from this commit):**
|
||||
```
|
||||
POST /memory/nodes/by-commit
|
||||
{
|
||||
"repo": "github.com/org/poimen",
|
||||
"commit_sha": "abc123def",
|
||||
"project": "poimen"
|
||||
}
|
||||
→ nodes from this commit + parent L1/L2 memories
|
||||
```
|
||||
|
||||
3. **By author (what did person X discover):**
|
||||
```
|
||||
POST /memory/nodes/by-author
|
||||
{
|
||||
"author": "[email protected]",
|
||||
"project": "poimen"
|
||||
}
|
||||
→ L0 nodes created during commits from alice
|
||||
```
|
||||
|
||||
**Query endpoint extension** (M3.5.3):
|
||||
Add optional `git_repo` param:
|
||||
```
|
||||
GET /memory/query?query=Kong&git_repo=github.com/org/poimen&project=poimen
|
||||
→ results enriched with git_context (file, commit, author)
|
||||
```
|
||||
|
||||
**Response format (all modes):**
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"sha256": "abc...",
|
||||
"level": "L0|L1|L2",
|
||||
"brief": "Kong body buffer raised",
|
||||
"git_ref": "src/kong/buffer.rs:42",
|
||||
"git_commit": {
|
||||
"sha": "abc123def",
|
||||
"message": "Increase body buffer to 16MB",
|
||||
"author": "[email protected]",
|
||||
"date": "2026-08-15T10:30:00Z"
|
||||
},
|
||||
"parents": [...]
|
||||
}
|
||||
],
|
||||
"repo": "github.com/org/poimen"
|
||||
}
|
||||
```
|
||||
|
||||
## Steps
|
||||
|
||||
1. `POST /memory/nodes/by-git` handler:
|
||||
- Parse `file`, `line`, `project`
|
||||
- Query: `SELECT * FROM memory_node WHERE project = $1 AND git_context->>'file' = $2 AND (git_context->>'line')::int = $3`
|
||||
- Walk edges to include parent L1/L2 nodes
|
||||
- Sort by created_at desc
|
||||
|
||||
2. `POST /memory/nodes/by-commit` handler:
|
||||
- Parse `commit_sha`, `project`
|
||||
- Query: `SELECT * FROM memory_node WHERE project = $1 AND git_context->>'commit_sha' = $2`
|
||||
- Include all L0 from this commit + transitive parents (L1/L2)
|
||||
|
||||
3. `POST /memory/nodes/by-author` handler:
|
||||
- Parse `author`, `project`
|
||||
- Query: `SELECT * FROM memory_node WHERE project = $1 AND git_context->>'author' = $2 AND level = 'L0'`
|
||||
- Walk edges to L1 parents
|
||||
|
||||
4. Extend M3.5.3 query handler:
|
||||
- Add optional `git_repo` query param
|
||||
- If provided, enrich response with git_context from each result node
|
||||
- Include `git_ref` in brief (file:line) for agent citation
|
||||
|
||||
5. Deduplication by git:
|
||||
- L0 evidence from same (file, line, commit) = same memory entry
|
||||
- Idempotency: ingesting same commit twice doesn't duplicate L0 nodes
|
||||
- Check: `(file, line, commit_sha)` tuple uniqueness constraint
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `by-git` lookup returns correct L0 evidence + parent memories
|
||||
- `by-commit` returns all evidence from that commit
|
||||
- `by-author` returns all discoveries by that author
|
||||
- Query results enriched with git_context when repo provided
|
||||
- Same evidence never duplicated (idempotent by git tuple)
|
||||
- Agents can cite by code location: "src/kong/buffer.rs:42 (commit abc123)"
|
||||
|
||||
## Verify
|
||||
|
||||
**Harness:** Integration tests with git history fixture.
|
||||
|
||||
**Setup:** Create test repo with commits:
|
||||
- commit abc123: modify src/kong/buffer.rs:42 (message: "Increase buffer")
|
||||
- commit def456: modify src/kong/handler.rs:10 (message: "Handle large bodies")
|
||||
- Create memory nodes with git_context from these commits
|
||||
|
||||
**Integration test** — `tests/it_git_references.rs`:
|
||||
1. `a1_by_git_lookup` — POST /nodes/by-git with file=buffer.rs, line=42 returns L0 from commit abc123.
|
||||
2. `a2_by_commit_lookup` — POST /nodes/by-commit with abc123 returns both L0 + parent L1/L2.
|
||||
3. `a3_by_author_lookup` — POST /nodes/by-author with alice@org returns all L0 from alice's commits.
|
||||
4. `a4_query_enriched_with_git` — GET /query?query=buffer&git_repo=... returns results with git_context populated.
|
||||
5. `a5_git_ref_in_brief` — result.git_ref = "src/kong/buffer.rs:42" (human-readable).
|
||||
6. `a6_idempotent_by_git_tuple` — ingest same commit twice, L0 nodes count stays 1 (no duplicates).
|
||||
7. `a7_edge_walk_preserves_git` — L1 parent of L0 node includes L0's git_context in parents array.
|
||||
8. `a8_cross_commit_correlation` — two commits affecting same file, both return from by-git lookup (line=0 or range?).
|
||||
9. `a9_author_query_filters_correctly` — two authors, by-author for alice returns only alice's L0.
|
||||
10. `a10_missing_git_context_graceful` — old L0 nodes without git_context (from before M3.5.2) still return but git_ref is null.
|
||||
|
||||
**Command:** `cargo test -p mem-cli git_references`
|
||||
|
||||
**False pass:**
|
||||
- Git context populated in fixture but never actually extracted from repo.git during ingest (M3.5.2). Test only checks stored data, not enrichment.
|
||||
- `by-git` returns results but never walks edges to L1. Parent L1 discoveries are invisible.
|
||||
- Query enrichment tested only with one repo. Multiple repos with overlapping filenames may return wrong results.
|
||||
- Idempotency tested with same commit but different git_repo URLs (github.com vs gitlab.com). Should be treated differently but test may not catch it.
|
||||
|
||||
## Traps
|
||||
|
||||
- Git blame is expensive. Caching blames by (file, commit_sha) pair is necessary for repeated queries.
|
||||
- Line numbers shift with edits. Reference to "line 42" in commit ABC may not match "line 42" in HEAD. Store commit hash, not line number, as primary key.
|
||||
- JSONB queries in PostgreSQL are slower than indexed columns. Consider denormalizing `git_file`, `git_commit`, `git_author` as separate columns if query volume is high.
|
||||
- Author name varies (alice@org vs alice.smith@org). Normalize email in ingest or handle fuzzy matching in by-author.
|
||||
- Cross-repo scenarios: same code in two repos (fork, mirror). git_repo must be part of uniqueness constraint.
|
||||
|
||||
---
|
||||
|
||||
Background: [DESIGN.md § Distributed API Layer](../DESIGN.md#distributed-api-layer-homelab-frontend)
|
||||
@@ -0,0 +1,133 @@
|
||||
# M3.6.1 — `DocCorpusSource` + heading-boundary chunking
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Phase | M3.6 — Reference corpora |
|
||||
| Size | M — 1–3 days |
|
||||
| Status | ⬜ Not started |
|
||||
| Flags | — |
|
||||
| Spec | inlined below |
|
||||
| Blocks | M3.6.6 |
|
||||
| Depends | M0.3, M0.4 |
|
||||
|
||||
## Goal
|
||||
|
||||
Read a tree of documentation into the same stream shape sessions use, split on
|
||||
headings instead of messages, without the gate ever seeing it.
|
||||
|
||||
## Facts (inlined — no spec read needed)
|
||||
|
||||
```rust
|
||||
pub enum Boundary {
|
||||
Record, // existing — never split mid-Record (sessions)
|
||||
Heading, // new — split on markdown ATX headings, never mid-section
|
||||
}
|
||||
```
|
||||
|
||||
`DocCorpusSource` is a third `RecordSource` alongside the pi and Claude adapters
|
||||
(M0.5, M0.6). It walks a directory, reads `*.md` and `*.txt`, and emits one
|
||||
`Record` per document section. The chunker never learns it came from a file tree
|
||||
rather than a socket — that is the whole point of the trait.
|
||||
|
||||
**Heading boundary, not record boundary.** A session Record is a natural unit; a
|
||||
markdown file is one Record of 8000 tokens with internal structure. Splitting a
|
||||
cheatsheet mid-table produces two chunks that are each individually useless.
|
||||
Split at `^#{1,6} ` and carry the heading path (`kubectl.md > Common Issues >
|
||||
CrashLoopBackOff`) onto every chunk as breadcrumb text.
|
||||
|
||||
A section longer than `max_tokens` still has to split. Fall back to
|
||||
`Boundary::Record` semantics within that section — paragraph boundaries, then
|
||||
hard split — and mark the continuation chunks so the projector can rejoin them
|
||||
for display.
|
||||
|
||||
**This task ends at the chunk stream.** No gate call, no embedding, no write. It
|
||||
is the M0.7 `--dry-run` shape applied to a doc tree: `mem ref add --dry-run`
|
||||
prints the plan and makes zero model calls.
|
||||
|
||||
**The divergence from the gated path is structural and belongs here.** `run_loop`
|
||||
(M1.5) takes a `Query`, and M1.2 makes an empty question a load error because the
|
||||
update gate is defined relative to `Q`. A corpus has no standing question, so the
|
||||
reference path must be unable to call the recurrence — not merely choose not to.
|
||||
Emit a distinct chunk type for this source so `run_loop` does not typecheck
|
||||
against it. A `skip_gate: bool` threaded through the shared path is the wrong
|
||||
shape: it defaults, and the default is one refactor away from feeding
|
||||
documentation to the controller.
|
||||
|
||||
Source URI is the identity anchor for everything downstream: an absolute path or
|
||||
`https://` URL, recorded per chunk, stable across re-ingest.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Add `Boundary::Heading` to `ChunkPolicy` in `mem-chunk`.
|
||||
2. Implement the heading splitter: parse ATX headings, build the heading path
|
||||
stack, emit sections with breadcrumb prefix.
|
||||
3. Implement over-long section fallback — paragraph split, then hard split, with
|
||||
a `continuation: true` marker on chunks 2..n.
|
||||
4. Implement `DocCorpusSource` in `mem-ingest`: walk dir, filter extensions, skip
|
||||
dotfiles and anything over a size ceiling, emit `Record` per section.
|
||||
5. Record `source_uri` and per-document `sha256` on every emitted record.
|
||||
6. Wire `mem ref add --dry-run <path>` to print the chunk plan: file, heading
|
||||
path, token count, chunk count.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- A doc tree yields one chunk per heading section, breadcrumbs attached.
|
||||
- No chunk crosses a heading boundary unless the section exceeded `max_tokens`.
|
||||
- An 8000-token section splits and every piece after the first is marked as a
|
||||
continuation.
|
||||
- `--dry-run` makes zero HTTP calls.
|
||||
- `DocCorpusSource` compiles against `RecordSource` with no trait change.
|
||||
|
||||
## Verify
|
||||
|
||||
**Harness:** a fixture doc tree under `fixtures/refcorpus/` — one small file, one
|
||||
file with nested headings, one file with a single 8000-token section, one
|
||||
non-markdown file that must be skipped.
|
||||
|
||||
**Integration test** — `tests/it_doc_corpus.rs`:
|
||||
1. `a1_section_per_heading` — nested-heading fixture yields exactly one chunk per
|
||||
ATX heading; assert count and order.
|
||||
2. `a2_breadcrumb_path` — a chunk under `## Common Issues > ### CrashLoopBackOff`
|
||||
carries the full heading path, not just the leaf.
|
||||
3. `a3_no_mid_section_split` — for every chunk, assert it contains at most one
|
||||
heading line and that heading is its first line.
|
||||
4. `a4_oversize_section_splits` — the 8000-token fixture yields >1 chunk, all
|
||||
under `max_tokens`, with `continuation: true` on all but the first.
|
||||
5. `a5_extension_filter` — the non-markdown file produces no chunks.
|
||||
6. `a6_source_uri_stable` — running the walk twice yields identical
|
||||
`(source_uri, sha256)` pairs.
|
||||
7. `a7_dry_run_no_network` — run under a transport that panics on any request;
|
||||
assert `--dry-run` completes.
|
||||
8. `a8_trait_object_safe` — `DocCorpusSource` is usable everywhere the pi adapter
|
||||
is, via the same `RecordSource` bound.
|
||||
9. `a9_reference_chunks_reject_the_loop` — a compile-fail test (`trybuild`)
|
||||
asserting `run_loop` cannot be called with this source's chunk type. The
|
||||
guarantee is "impossible", so the test has to be a compile error; a runtime
|
||||
assertion proves only that today's caller happens not to do it.
|
||||
|
||||
**Command:** `cargo test -p mem-ingest doc_corpus`
|
||||
|
||||
**False pass:**
|
||||
- Asserting chunk count only. A splitter that emits the right number of chunks
|
||||
by hard-splitting on token count hits the count and fails assertion 3, which
|
||||
is the one that proves headings were used at all.
|
||||
- Testing the walk on a single flat file. Nested heading paths are where the
|
||||
breadcrumb logic breaks, and a flat fixture never exercises the stack.
|
||||
|
||||
## Traps
|
||||
|
||||
- Emitting the breadcrumb as metadata only. The embedding is computed over chunk
|
||||
text; a heading path that is not *in* the text does not reach the vector, and
|
||||
"CrashLoopBackOff" stops being findable from the section body alone.
|
||||
- Treating setext headings (`===` underlines) as prose. They are rarer in
|
||||
generated docs but they exist, and a file that uses them degrades silently to
|
||||
one enormous chunk.
|
||||
- Walking symlinks. A docs tree with a self-referential link makes the walk hang
|
||||
with no output, which reads as a slow embed rather than a loop.
|
||||
- Adding the corpus to `sources:` in a standing-query YAML. That list names the
|
||||
*evidence* sources for a question; a corpus listed there is documentation
|
||||
entering the gate, which is the one outcome this phase exists to prevent.
|
||||
|
||||
---
|
||||
|
||||
Background: [DESIGN.md](../DESIGN.md) — reference corpora, `mem-chunk`
|
||||
@@ -0,0 +1,125 @@
|
||||
# M3.6.2 — Level R: log record, index rows, vault notes, rebuild parity
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Phase | M3.6 — Reference corpora |
|
||||
| Size | M — 1–3 days |
|
||||
| Status | ⬜ Not started |
|
||||
| Flags | — |
|
||||
| Spec | inlined below |
|
||||
| Blocks | M3.6.6 |
|
||||
| Depends | M3.6.1, M1.6, M2.3, M2.4, M2.5, M2.6 |
|
||||
|
||||
## Goal
|
||||
|
||||
Land reference chunks in the log as their own record kind, project them into
|
||||
Postgres and the vault, and prove the projections are still throwaway.
|
||||
|
||||
## Facts (inlined — no spec read needed)
|
||||
|
||||
```jsonl
|
||||
{"kind":"reference","level":"R","project":"homelab","source":"file:///.../kubectl.md",
|
||||
"heading_path":"kubectl.md > Common Issues > CrashLoopBackOff","doc_sha":"ab12…",
|
||||
"sha256":"cd34…","t":7,"run_id":"ref-2026-08-21T10:02:11Z","text":"…"}
|
||||
```
|
||||
|
||||
**No migration is needed.** `'R'` ships in M2.3's initial schema, along with the
|
||||
`CHECK ((level IN ('L2','R')) = (query_id IS NULL))` constraint. Nothing was built
|
||||
before this phase existed, so the level was never absent from the schema and an
|
||||
`ALTER` here would only undo a deliberate omission that was never made.
|
||||
|
||||
`level = 'R'`, `query_id = NULL` (R answers no standing question), `source` holds
|
||||
the source URI. `doc_sha` is the whole-document hash; `sha256` is the chunk hash
|
||||
and stays the primary identity, same as every other level.
|
||||
|
||||
The embedding goes to `memory_vector(kind='text')`, not to a column on the node.
|
||||
R gets **no symptom projection** — M3.7.8 generates those for L1 and L2 only,
|
||||
since documentation headings already read like problems.
|
||||
|
||||
**R writes no edges.** Not to parents, not to siblings. A reference chunk has no
|
||||
provenance inside this system — its provenance is the URI. The rule that makes
|
||||
this safe is enforced in `mem verify` (M3.6.4), but nothing in this task should
|
||||
ever be tempted to create an edge in the first place.
|
||||
|
||||
**Vault projection goes somewhere separate.** `vault/reference/<corpus>/<doc>.md`,
|
||||
not into the project notes. The vault is browsed by a human; interleaving
|
||||
upstream docs with synthesized project memory makes the vault untrustworthy at a
|
||||
glance. One note per source document, sections as headings, each carrying its
|
||||
chunk sha as an anchor so `mem query` output can deep-link.
|
||||
|
||||
**Rebuild parity is the whole point of the task.** `mem rebuild --from-log` must
|
||||
drop and reconstruct R rows and R notes byte-identically. If it cannot, R has
|
||||
hidden inputs and rule 3 of the design is broken — M2.8 already enforces this
|
||||
property for L0/L1/L2 and this task extends the same harness rather than writing
|
||||
a second one.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Add the `Reference` variant to the log record enum in `mem-core`; serialize
|
||||
with the field set above.
|
||||
2. `mem-store`: insert R nodes with a single `kind='text'` vector; assert at the
|
||||
repository boundary that no edge insert names an R sha as parent.
|
||||
4. Obsidian projector: `vault/reference/<corpus>/<doc>.md`, one note per source
|
||||
document, chunk shas as heading anchors.
|
||||
5. Extend `mem rebuild --from-log` to replay `Reference` records.
|
||||
6. Extend the M2.6 rebuild-parity harness to cover a log containing R records.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- A `Reference` record round-trips through the log unchanged.
|
||||
- R rows land with `query_id IS NULL` and `source` set to the URI.
|
||||
- The widened constraint accepts `R` and still rejects `L3`.
|
||||
- Reference notes land under `vault/reference/`, never in project note dirs.
|
||||
- Drop database + vault, `mem rebuild --from-log`, and both come back
|
||||
byte-identical.
|
||||
|
||||
## Verify
|
||||
|
||||
**Harness:** the M2.6 rebuild harness, extended with a log fixture that contains
|
||||
L0/L1/L2 *and* R records. Deterministic fake embedder so shas are stable.
|
||||
|
||||
**Integration test** — `tests/it_level_r_storage.rs`:
|
||||
1. `a1_record_roundtrip` — serialize then deserialize a `Reference` record;
|
||||
assert field-for-field equality including `doc_sha` and `heading_path`.
|
||||
2. `a2_r_inserts` — insert `level='R'` with a `kind='text'` vector; assert both
|
||||
rows persist.
|
||||
3. `a3_no_symptom_vector` — assert no R node acquires a `kind='symptom'` vector
|
||||
after a full ingest.
|
||||
4. `a4_query_id_null_at_r` — assert every R row has `query_id IS NULL`, and that
|
||||
an R row with one is rejected by M2.3's CHECK.
|
||||
5. `a5_no_edges_from_r` — after ingesting the fixture corpus, assert
|
||||
`SELECT count(*) FROM memory_edge WHERE parent_sha IN (SELECT sha256 FROM
|
||||
memory_node WHERE level='R')` is 0.
|
||||
6. `a6_vault_path_isolation` — assert every emitted reference note path starts
|
||||
with `vault/reference/` and no project note directory gained a file.
|
||||
7. `a7_rebuild_byte_identical` — snapshot database rows and vault files, drop
|
||||
both, `mem rebuild --from-log`, assert byte-identical including R.
|
||||
8. `a8_rebuild_is_idempotent` — rebuild twice; assert the second run changes
|
||||
nothing.
|
||||
|
||||
**Command:** `cargo test -p mem-store level_r && cargo test -p mem-cli rebuild`
|
||||
|
||||
**False pass:**
|
||||
- Asserting rebuild parity on a log with no R records. It passes trivially and
|
||||
proves nothing about this task; assertion 7 is only meaningful because the
|
||||
fixture log is mixed-level.
|
||||
- Checking edge count is zero *before* ingesting anything. Assertion 5 has to run
|
||||
against a populated corpus or it is asserting that an empty table is empty.
|
||||
- Comparing vault files with a normalizing diff. Byte-identical means bytes;
|
||||
trailing-newline drift is exactly the class of hidden input this rule exists
|
||||
to catch.
|
||||
|
||||
## Traps
|
||||
|
||||
- Reusing `run_id` semantics from the gated loop. R has no run in the recurrence
|
||||
sense; use a synthetic `ref-<timestamp>` and do not let it collide with a real
|
||||
ingest run in queries that group by `run_id`.
|
||||
- Putting reference notes in the project vault "just for now". The vault is the
|
||||
human surface and the mixing is not reversible by a later move — links written
|
||||
against the old path rot.
|
||||
- Dropping the check constraint instead of widening it. Assertion 3 exists
|
||||
because `DROP CONSTRAINT` alone passes every other assertion in this file.
|
||||
|
||||
---
|
||||
|
||||
Background: [DESIGN.md](../DESIGN.md) — reference corpora, storage schemas
|
||||
@@ -0,0 +1,118 @@
|
||||
# M3.6.3 — `mem ref` — corpus management with replace-on-change
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Phase | M3.6 — Reference corpora |
|
||||
| Size | M — 1–3 days |
|
||||
| Status | ⬜ Not started |
|
||||
| Flags | — |
|
||||
| Spec | inlined below |
|
||||
| Blocks | M3.6.6 |
|
||||
| Depends | M3.6.2, M2.1 |
|
||||
|
||||
## Goal
|
||||
|
||||
Add, list, refresh and remove reference corpora, so that re-running an ingest
|
||||
against changed upstream docs replaces what is there instead of stacking a second
|
||||
copy beside it.
|
||||
|
||||
## Facts (inlined — no spec read needed)
|
||||
|
||||
```
|
||||
mem ref add --project homelab --corpus kubectl ~/workplace/homelab/knowledge/cheatsheets
|
||||
mem ref add --dry-run ... # chunk plan only, zero model calls (M3.6.1)
|
||||
mem ref list --project homelab # corpus, docs, chunks, last ingest, drift
|
||||
mem ref sync --corpus kubectl # re-walk, replace changed docs, report
|
||||
mem ref rm --corpus kubectl # tombstone every doc in the corpus
|
||||
```
|
||||
|
||||
**Identity is `(source_uri, doc_sha)`.** Same URI and same sha is a no-op: no
|
||||
embed call, no write, exit 0 with "unchanged". Same URI and different sha is a
|
||||
*replace*: tombstone the old chunks in the log, write the new ones. A URI that
|
||||
has vanished from the tree on a `sync` is a tombstone with no successor.
|
||||
|
||||
**Tombstone, do not delete.** The log is append-only and authoritative. A
|
||||
`{"kind":"reference_tombstone","sha256":"…","reason":"replaced"}` record is what
|
||||
removal means; the projector drops the row and the note on replay. Deleting rows
|
||||
from Postgres directly makes the index un-rebuildable, which is the one thing the
|
||||
whole design refuses.
|
||||
|
||||
**Embedding is the expensive step, so skip it precisely.** A corpus of 400 chunks
|
||||
where one document changed should issue embeddings for that document's chunks
|
||||
only. Chunk-level sha comparison, not document-level re-embed.
|
||||
|
||||
**`list` reports drift.** For each corpus, re-stat the tree and compare doc shas
|
||||
without writing anything: `3 docs changed, 1 removed, 12 unchanged`. Drift that
|
||||
is only discoverable by running `sync` means nobody runs `sync`.
|
||||
|
||||
## Steps
|
||||
|
||||
1. `mem ref add [--project P] --corpus C [--dry-run] <path>` — walk via
|
||||
`DocCorpusSource`, embed new chunks, write `Reference` records.
|
||||
2. Persist corpus registration (name, root path, project, last ingest) in the
|
||||
log as a `reference_corpus` record so `list` needs no side file.
|
||||
3. Implement chunk-level diff: existing shas for the corpus vs freshly walked
|
||||
shas → `{new, changed, unchanged, gone}`.
|
||||
4. `mem ref sync` — apply the diff, embedding only `new` and `changed`, emitting
|
||||
tombstones for `gone`.
|
||||
5. `mem ref list` — table per corpus with counts plus a dry drift check.
|
||||
6. `mem ref rm` — tombstone every live chunk in the corpus; leave the log intact.
|
||||
7. Exit codes: 0 on success including no-op, non-zero on unresolvable corpus or
|
||||
unreadable root.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `add` twice on an unchanged tree issues zero embedding calls the second time.
|
||||
- Editing one file and running `sync` re-embeds that file's chunks only.
|
||||
- Deleting a file and running `sync` tombstones its chunks, and it stops
|
||||
appearing in query results.
|
||||
- `rm` removes the corpus from results while leaving every record in the log.
|
||||
- `list` reports drift without mutating anything.
|
||||
|
||||
## Verify
|
||||
|
||||
**Harness:** fixture tree copied to a temp dir so it can be mutated, a counting
|
||||
embedder that records how many texts it was asked to embed, seeded database.
|
||||
|
||||
**Integration test** — `tests/it_mem_ref.rs`:
|
||||
1. `a1_add_then_add_is_noop` — run `add` twice; assert the embedder call count is
|
||||
zero on the second run and the row count is unchanged.
|
||||
2. `a2_changed_doc_reembeds_only_itself` — edit one file of three, `sync`; assert
|
||||
embed count equals that file's chunk count, not the corpus total.
|
||||
3. `a3_replace_tombstones_predecessor` — after a change, assert the old chunk
|
||||
sha has a tombstone record and no live row.
|
||||
4. `a4_removed_doc_tombstoned` — delete a file, `sync`; assert its chunks are
|
||||
gone from `memory_node` and present in the log.
|
||||
5. `a5_rm_preserves_log` — count log lines before and after `rm`; assert the
|
||||
count only grew.
|
||||
6. `a6_rebuild_after_churn` — after add/change/sync/rm, `mem rebuild --from-log`;
|
||||
assert the reconstructed state matches the live state exactly.
|
||||
7. `a7_list_is_read_only` — snapshot database and log, run `list`, assert both
|
||||
unchanged and that reported drift matches the mutations made.
|
||||
8. `a8_unreadable_root_exits_nonzero` — point `add` at a missing path; assert
|
||||
non-zero exit and no partial corpus registration.
|
||||
|
||||
**Command:** `cargo test -p mem-cli mem_ref`
|
||||
|
||||
**False pass:**
|
||||
- Asserting "no duplicate rows" instead of counting embedder calls. A `sync` that
|
||||
re-embeds everything and then upserts by sha produces a correct table and a
|
||||
bill; assertion 1 and 2 are the only ones that see it.
|
||||
- Verifying tombstones by querying `memory_node`. The row being absent is the
|
||||
projector working; assertion 3 has to read the log to prove the tombstone was
|
||||
actually written and the row was not just deleted.
|
||||
- Running the churn test without a final rebuild. Assertion 6 is what proves the
|
||||
tombstone replay logic exists rather than being implied.
|
||||
|
||||
## Traps
|
||||
|
||||
- Registering the corpus before the walk succeeds. A failed `add` that leaves a
|
||||
registered-but-empty corpus makes the next `sync` report every document as new.
|
||||
- Comparing document mtime instead of sha. Checkouts and rsync rewrite mtimes;
|
||||
a corpus that re-embeds on every clone costs real money on the TEI endpoint.
|
||||
- Making `rm` delete log records "because they are noise". That converts the log
|
||||
from authoritative to advisory, and nothing downstream can tell.
|
||||
|
||||
---
|
||||
|
||||
Background: [DESIGN.md](../DESIGN.md) — reference corpora, JSONL event log
|
||||
@@ -0,0 +1,142 @@
|
||||
# M3.6.4 — Reference text cannot re-enter as evidence
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Phase | M3.6 — Reference corpora |
|
||||
| Size | M — 1–3 days |
|
||||
| Status | ⬜ Not started |
|
||||
| Flags | — |
|
||||
| Spec | inlined below |
|
||||
| Blocks | M3.6.6 |
|
||||
| Depends | M3.6.3, M4.2 |
|
||||
|
||||
## Goal
|
||||
|
||||
Stop a retrieved manual page from coming back through the front door as a project
|
||||
finding.
|
||||
|
||||
## Facts (inlined — no spec read needed)
|
||||
|
||||
The cycle is M4.2's, with documentation substituted for emitted skills:
|
||||
|
||||
```
|
||||
agent queries memory, gets an R section
|
||||
│
|
||||
▼
|
||||
section is pasted into the agent's context
|
||||
│
|
||||
▼
|
||||
appears verbatim in that session's transcript
|
||||
│
|
||||
▼
|
||||
transcript ingested; the gate sees upstream doc text
|
||||
│
|
||||
▼
|
||||
"kubectl describe shows events" becomes an L1 project memory
|
||||
```
|
||||
|
||||
The gate is *right* to accept it — the chunk genuinely contains information about
|
||||
the question. That is what makes this dangerous rather than merely noisy: no
|
||||
threshold tuning catches it, because the text really is relevant. Only knowing
|
||||
that the system emitted the text itself distinguishes the two cases.
|
||||
|
||||
**Reuse M4.2, do not rebuild it.** M4.2 already computes normalised shingle
|
||||
overlap against an artifact manifest and tags matching records `derived: true`,
|
||||
excluding them from evidence while keeping them in the log so the exclusion is
|
||||
auditable. R chunks are a second artifact kind in that same manifest. A parallel
|
||||
matcher would drift from it and double the tuning surface.
|
||||
|
||||
```jsonl
|
||||
{"kind":"reference","name":"kubectl/common-issues","sha256":"cd34…","shingles":[…],"emitted_at":"…"}
|
||||
{"kind":"skill","name":"infra-root-causes","sha256":"ab12…","shingles":[…],"emitted_at":"…"}
|
||||
```
|
||||
|
||||
**Threshold pressure differs by kind and this is the real work.** A skill is
|
||||
emitted once and quoted rarely. Documentation is quoted constantly and partially
|
||||
— one command from a fifty-line cheatsheet. Shingle overlap against a whole R
|
||||
chunk will sit far below M4.2's 0.8 default for exactly the case that matters, so
|
||||
matching must be at section granularity with its own threshold, tuned and logged
|
||||
separately. One shared matcher, two configured thresholds.
|
||||
|
||||
**A false positive here is costly and must stay visible.** Excluding a genuine
|
||||
discussion *about* `kubectl` because it quotes two lines of the cheatsheet
|
||||
silently drops real evidence. Every exclusion emits `derived_excluded` naming the
|
||||
matched artifact, and `mem verify` can list them for audit.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Generalise M4.2's manifest to `vault/.artifacts.jsonl` with a `kind` field;
|
||||
keep skills writing to it unchanged.
|
||||
2. `mem ref add`/`sync` append `kind: "reference"` entries per R chunk;
|
||||
tombstones remove them.
|
||||
3. Add per-kind thresholds to the matcher config; default reference threshold
|
||||
lower than the skill threshold, and record the value in the exclusion event.
|
||||
4. Extend `mem verify --derived-filter` to assert no L0 evidence node matches a
|
||||
live R artifact.
|
||||
5. `mem verify --exclusions` lists recent `derived_excluded` events with the
|
||||
matched artifact and overlap score, for false-positive review.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- A session transcript containing a verbatim R section is excluded from evidence.
|
||||
- The same transcript still appears in the log, tagged, with the match named.
|
||||
- A session that merely *mentions* the tool without quoting it is not excluded.
|
||||
- Skill exclusion behaviour from M4.2 is unchanged.
|
||||
- Removing a corpus removes its manifest entries; previously excluded text is not
|
||||
retroactively rewritten in the log.
|
||||
|
||||
## Verify
|
||||
|
||||
**Harness:** fixture corpus ingested as R, plus three synthetic transcripts — one
|
||||
quoting a section verbatim, one paraphrasing it heavily, one discussing the tool
|
||||
without quoting. Deterministic embedder.
|
||||
|
||||
**Integration test** — `tests/it_reference_cycle.rs`:
|
||||
1. `a1_verbatim_quote_excluded` — the quoting transcript produces zero L0
|
||||
evidence nodes; assert a `derived_excluded` event naming the R artifact.
|
||||
2. `a2_discussion_not_excluded` — the non-quoting transcript produces evidence
|
||||
normally. This is the false-positive guard and it is the assertion that fails
|
||||
when the threshold is set too low.
|
||||
3. `a3_partial_quote_caught` — the transcript quoting ~10 lines of a 50-line
|
||||
section is excluded, proving section-granularity matching rather than
|
||||
whole-chunk overlap.
|
||||
4. `a4_skill_path_unchanged` — run M4.2's own test fixtures; assert identical
|
||||
results before and after the manifest generalisation.
|
||||
5. `a5_exclusion_is_auditable` — every exclusion event carries artifact name,
|
||||
overlap score and the threshold in force.
|
||||
6. `a6_tombstone_removes_manifest_entry` — `mem ref rm`, then assert the R
|
||||
entries are gone from the manifest and the same transcript now ingests
|
||||
normally.
|
||||
7. `a7_verify_catches_leak` — hand-insert an L0 node whose text matches an R
|
||||
artifact; assert `mem verify --derived-filter` exits non-zero and names it.
|
||||
8. `a8_no_retroactive_log_edit` — after `rm`, assert prior `derived_excluded`
|
||||
records are still present and unmodified.
|
||||
|
||||
**Command:** `cargo test -p mem-ingest reference_cycle && cargo test -p mem-cli verify`
|
||||
|
||||
**False pass:**
|
||||
- Testing only the verbatim case. Verbatim is easy and a whole-chunk hash catches
|
||||
it; assertion 3 is the one that distinguishes a working matcher, and assertion
|
||||
2 is the one that proves it is not simply excluding everything that mentions
|
||||
the tool.
|
||||
- Asserting exclusion by checking evidence count is zero. A filter that is
|
||||
accidentally excluding *all* records also yields zero; assertion 2 has to run
|
||||
in the same test binary.
|
||||
- Reusing M4.2's threshold unchanged and declaring it done. The default is tuned
|
||||
for whole-artifact quoting; assertion 3 fails against it, which is the point.
|
||||
|
||||
## Traps
|
||||
|
||||
- Registering R chunks in the manifest before they are committed to the log. A
|
||||
failed ingest then leaves manifest entries that exclude evidence for a corpus
|
||||
that does not exist, and the symptom is missing memories with no obvious cause.
|
||||
- Normalising differently in the two paths. If the shingler treats markdown
|
||||
tables differently at emit-time and at ingest-time, overlap collapses and the
|
||||
filter silently stops firing — same failure M4.2 already warns about, now with
|
||||
two producers to keep in step.
|
||||
- Letting the exclusion event omit the threshold. A tuning change makes every
|
||||
historical exclusion uninterpretable, and this filter will be tuned.
|
||||
|
||||
---
|
||||
|
||||
Background: [DESIGN.md](../DESIGN.md) — reference corpora, skills · [M4.2](M4.2-derived-filter.md)
|
||||
@@ -0,0 +1,136 @@
|
||||
# M3.6.5 — Query: filter-then-recall, R opt-in, relevance floor
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Phase | M3.6 — Reference corpora |
|
||||
| Size | M — 1–3 days |
|
||||
| Status | ⬜ Not started |
|
||||
| Flags | — |
|
||||
| Spec | inlined below |
|
||||
| Blocks | M3.6.6 |
|
||||
| Depends | M3.6.2, M3.3, M3.2, M2.7 |
|
||||
|
||||
## Goal
|
||||
|
||||
Make R reachable on request, unreachable by default, and stop the retriever
|
||||
answering questions it has no evidence for.
|
||||
|
||||
## Facts (inlined — no spec read needed)
|
||||
|
||||
```
|
||||
mem query "why did requests over 10KB fail?" # L1,L2 — unchanged
|
||||
mem query --levels R "kubectl describe pod" # reference only
|
||||
mem query --levels L1,L2,R "..." # both, R marked in output
|
||||
mem query "…" --min-score 0.4 # override the floor
|
||||
```
|
||||
|
||||
**Filter before recall, not after.** M3.3 recalls `10×k` from HNSW and reranks
|
||||
down to `k`. A corpus is typically an order of magnitude larger than the project's
|
||||
own memory, so R rows compete for those 50 candidate slots even when the caller
|
||||
excluded them — and post-filtering then returns three results instead of five,
|
||||
quietly. The level predicate belongs in the SQL that drives the HNSW scan. The
|
||||
existing `(project, level)` index already supports it.
|
||||
|
||||
**Abstention.** With a corpus loaded, every question has *something* moderately
|
||||
close, so unconditional top-k starts returning plausible prose for questions the
|
||||
memory cannot answer — worse than an empty result, because it reads as an answer.
|
||||
If the best post-rerank score is below the floor, return no hits and say why:
|
||||
|
||||
```
|
||||
no hits above relevance floor (best 0.21 < 0.35 threshold)
|
||||
try --min-score to lower it, or --levels R to search reference docs
|
||||
```
|
||||
|
||||
The floor applies to the **reranked** score, not cosine distance. M3.2's own
|
||||
fixture separates a relevant from an irrelevant passage by four orders of
|
||||
magnitude; cosine distance does not, which is why the floor cannot live at the
|
||||
recall stage.
|
||||
|
||||
**R is visually distinct in output.** A reference hit prints its source URI and
|
||||
heading path where a project hit prints provenance. A caller must never have to
|
||||
infer from wording whether an answer came from this cluster's history or from
|
||||
upstream documentation.
|
||||
|
||||
**R has no provenance walk.** M3.3 walks `memory_edge` one hop for L1 and two for
|
||||
L2. R has no edges by construction (M3.6.2), so the walk is skipped rather than
|
||||
returning empty — and `mem verify` gains the assertion that makes that safe.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Push the level filter into the recall query; assert candidate width is `10×k`
|
||||
*after* filtering.
|
||||
2. `--levels` accepts `R`; default remains `L1,L2`.
|
||||
3. Apply the relevance floor to reranked scores; `--min-score` overrides,
|
||||
`--min-score 0` disables.
|
||||
4. Abstention message names the best score, the threshold, and the two escapes.
|
||||
5. Render R hits with source URI and heading path; suppress the provenance walk.
|
||||
6. Exit code: abstention is exit 0 with no hits, not an error — it is a valid
|
||||
answer. Unresolvable project stays non-zero (M3.3 assertion 7).
|
||||
7. `mem verify --edges` asserts no `memory_edge` row names an R sha as parent.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Default query over a database containing a large corpus returns exactly the
|
||||
same hits as before the corpus was added.
|
||||
- `--levels R` returns reference sections with URI and heading path.
|
||||
- A question with no good match returns nothing and explains itself.
|
||||
- Lowering `--min-score` surfaces the suppressed hits.
|
||||
- `mem verify` rejects a hand-inserted `L1 -> R` edge.
|
||||
|
||||
## Verify
|
||||
|
||||
**Harness:** seeded database with the poimen log *plus* a reference corpus large
|
||||
enough to dominate raw recall — at least 10× the project node count. Live
|
||||
reranker for scoring assertions, deterministic embedder elsewhere.
|
||||
|
||||
**Integration test** — `tests/it_query_levels.rs`:
|
||||
1. `a1_default_unchanged_by_corpus` — snapshot default query results before and
|
||||
after ingesting the corpus; assert byte-identical output. This is the
|
||||
headline assertion of the task.
|
||||
2. `a2_filter_before_recall` — instrument the repository; assert the SQL driving
|
||||
HNSW carries the level predicate and returns `10×k` rows post-filter, not
|
||||
`10×k` pre-filter then fewer.
|
||||
3. `a3_levels_r_returns_reference` — `--levels R` returns R nodes with source URI
|
||||
and heading path populated.
|
||||
4. `a4_floor_abstains` — a question with no relevant content returns zero hits,
|
||||
exit 0, message naming best score and threshold.
|
||||
5. `a5_floor_override_recovers` — same question with `--min-score 0` returns the
|
||||
suppressed hits, proving abstention is a floor and not a bug upstream.
|
||||
6. `a6_floor_applies_post_rerank` — construct a case where cosine is high and
|
||||
rerank is low; assert it is suppressed. The reverse ordering passes every
|
||||
other assertion here.
|
||||
7. `a7_r_hits_visually_distinct` — assert R hits carry no provenance block and do
|
||||
carry a URI, in both human and `--format json` output.
|
||||
8. `a8_no_edge_to_r` — hand-insert an `L1 -> R` edge; assert `mem verify --edges`
|
||||
exits non-zero and names the offending pair.
|
||||
9. `a9_mixed_levels_ordering` — `--levels L1,L2,R` returns both kinds ranked
|
||||
together with the level labelled on every row.
|
||||
|
||||
**Command:** `cargo test -p mem-cli query_levels`
|
||||
|
||||
**False pass:**
|
||||
- Running assertion 1 against a small corpus. If the corpus is smaller than the
|
||||
recall width, post-filtering and pre-filtering give the same answer and
|
||||
assertion 2 is the only thing separating them — the fixture size is part of the
|
||||
test.
|
||||
- Testing abstention with a query that matches nothing at all. Zero recall
|
||||
returns zero hits regardless of the floor; the fixture needs a *weak but
|
||||
non-empty* match, or assertion 4 passes with the floor unimplemented.
|
||||
- Asserting `--levels R` works without asserting the default excludes R. Both
|
||||
directions are the contract.
|
||||
|
||||
## Traps
|
||||
|
||||
- Applying the floor to the first-stage cosine score. Cosine on `nomic` puts
|
||||
unrelated text closer than intuition suggests; a floor there either suppresses
|
||||
good hits or does nothing, depending on the corpus.
|
||||
- Treating abstention as an error exit. Callers wrap `mem query` in scripts; a
|
||||
non-zero exit for "no confident answer" turns a normal outcome into a pipeline
|
||||
failure and the floor gets disabled within a week.
|
||||
- Letting the reranker see 50 R candidates and 3 project candidates in a mixed
|
||||
query. The reranker is not calibrated across levels, and the corpus wins on
|
||||
fluency; recall per level, then merge.
|
||||
|
||||
---
|
||||
|
||||
Background: [DESIGN.md](../DESIGN.md) — reference corpora, pgvector, retrieval
|
||||
@@ -0,0 +1,134 @@
|
||||
# M3.6.6 — M3.6 composition gate
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Phase | M3.6 — Reference corpora |
|
||||
| Size | M — 1–3 days |
|
||||
| Status | ⬜ Not started |
|
||||
| Flags | gate |
|
||||
| Spec | inlined below |
|
||||
| Blocks | all of M3.6 |
|
||||
| Depends | M3.6.1, M3.6.2, M3.6.3, M3.6.4, M3.6.5 |
|
||||
|
||||
## Goal
|
||||
|
||||
Answer the question no single task in this phase can: **did adding documentation
|
||||
change the memory system?** It must not have.
|
||||
|
||||
## Facts (inlined — no spec read needed)
|
||||
|
||||
Every task here was verified alone. What none of them own is the property that
|
||||
makes the phase safe: a corpus is additive to *retrieval* and invisible to
|
||||
*everything else*. Three ways that can silently fail, and this gate exists for
|
||||
them.
|
||||
|
||||
**1. The M1.8 metric can be gamed by accident.** Update-rate is
|
||||
`chunks_used / chunks_seen`, and M1 fails above 30%. Documentation is
|
||||
evidence-free against almost any standing question, so a corpus routed through
|
||||
the controller would push the ratio *down* and make M1.8 easier to pass while the
|
||||
memory got worse. Any implementation that improves a quality metric by adding
|
||||
unrelated text has inverted it. The gate re-runs M1.8 and asserts the numbers are
|
||||
**unchanged**, not merely still-passing.
|
||||
|
||||
**2. Retrieval quality can degrade without any test noticing.** Each task asserts
|
||||
its own behaviour on its own fixture. The composite risk is a corpus that
|
||||
outcompetes real project memory in the candidate pool — invisible to M3.6.5's
|
||||
unit fixture, obvious on the real poimen log with a real corpus loaded.
|
||||
|
||||
**3. The cycle guard has two producers now.** M4.2 writes skills to the manifest,
|
||||
M3.6.4 writes reference sections. They share a normaliser. Skills exclusion
|
||||
regressing when a corpus is added is the failure that no test in either phase
|
||||
catches, because each tests only its own kind.
|
||||
|
||||
**Swappable parts.** The phase claims two seams are real: `DocCorpusSource` is
|
||||
just another `RecordSource`, and the corpus is just another projection input.
|
||||
Prove both — swap the doc tree for a differently-shaped one and re-run, and
|
||||
rebuild the whole store from the log with the corpus present.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Establish the baseline: on a clean store, run `mem ingest --project poimen`
|
||||
for all standing queries; record the M1.8 summary table.
|
||||
2. `mem ref add --project poimen --corpus homelab-knowledge <tree>` against a
|
||||
real corpus of at least 200 chunks.
|
||||
3. Re-run the full ingest. Diff the M1.8 summary against the baseline.
|
||||
4. Run the assertions below.
|
||||
5. Emit `expected/m3.6-gate.txt` with the summary; commit it. Later runs diff
|
||||
against it and a changed expectation is a reviewable claim, same rule as M1.8.
|
||||
6. Sample 10 abstentions and 10 R hits; eyeball whether the floor is set sanely.
|
||||
Advisory, as M1.8's judge audit is.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- M1.8's numbers are identical before and after the corpus exists.
|
||||
- Default query output is byte-identical before and after.
|
||||
- No L1 or L2 node has an R parent.
|
||||
- Skills exclusion behaviour is unchanged with a corpus loaded.
|
||||
- Drop and rebuild reproduces the mixed store byte-identically.
|
||||
|
||||
## Verify
|
||||
|
||||
**Harness:** live gateway, real corpus, real poimen log. Long-running; nightly or
|
||||
on-demand, `#[ignore]` by default, same posture as M1.8.
|
||||
|
||||
**Integration test** — `tests/it_m3_6_gate.rs`:
|
||||
1. `a1_update_rate_identical` — per standing query, assert update-rate before and
|
||||
after the corpus is added is equal, not merely both under 0.30. Equality is
|
||||
the assertion; a threshold check here would pass the exact failure described
|
||||
above.
|
||||
2. `a2_chunks_seen_identical` — `chunks_seen` per run is unchanged, proving no R
|
||||
chunk entered the recurrence.
|
||||
3. `a3_no_controller_calls_during_ref_ingest` — run `mem ref add` under a chat
|
||||
transport that panics on request; assert it completes. Embeddings are allowed,
|
||||
controller calls are not, so the fake must distinguish the two endpoints.
|
||||
4. `a4_default_query_byte_identical` — snapshot default `mem query` output for 10
|
||||
fixed questions before and after; assert byte-identical.
|
||||
5. `a5_no_r_parents` — `SELECT count(*) FROM memory_edge WHERE parent_sha IN
|
||||
(SELECT sha256 FROM memory_node WHERE level='R')` is 0 on the live store.
|
||||
6. `a6_l2_stream_excludes_r` — re-run L2 synthesis; assert its input stream
|
||||
contained only L1 nodes and the resulting L2 memories have no R ancestor.
|
||||
7. `a7_skill_exclusion_unregressed` — re-run M4.2's fixtures against the store
|
||||
with the corpus loaded; assert identical exclusion decisions.
|
||||
8. `a8_rebuild_mixed_store` — drop database and vault, `mem rebuild --from-log`,
|
||||
assert byte-identical across all four levels.
|
||||
9. `a9_source_seam_swappable` — point `mem ref add` at a structurally different
|
||||
tree (deep nesting, no headings in one file, one non-UTF8 file) and assert it
|
||||
ingests or fails cleanly, never partially.
|
||||
10. `a10_corpus_does_not_starve_recall` — for 10 project questions, assert the
|
||||
top-5 default hits are the same nodes as the pre-corpus baseline, with the
|
||||
corpus present in the table.
|
||||
11. `a11_m5_export_excludes_r` — run the M5.3 training-corpus export shape; assert
|
||||
zero R records appear. R carries no gate decision, so its presence would
|
||||
poison `r_update` labels with rows that have no ground truth.
|
||||
|
||||
**Command:** `cargo test --workspace m3_6_gate -- --ignored --nocapture`
|
||||
|
||||
**False pass:**
|
||||
- Asserting update-rate is still below 30% instead of unchanged. That is the
|
||||
precise shape that goes green while the gate is being fed documentation —
|
||||
assertion 1 must be equality.
|
||||
- Running the gate with a corpus small enough not to matter. 200 chunks is a
|
||||
floor, not a suggestion; below it, assertions 4 and 10 pass because the corpus
|
||||
never reaches the candidate pool.
|
||||
- Allowing `a3`'s fake transport to reject all HTTP. Reference ingest legitimately
|
||||
calls the embeddings endpoint; a blanket panic passes the assertion for the
|
||||
wrong reason and would also pass if ingest did nothing at all.
|
||||
- Rebuilding into a fresh database rather than dropping the live one. A rebuild
|
||||
that never exercises deletion has not proved the projections are droppable.
|
||||
|
||||
## Traps
|
||||
|
||||
- Comparing M1.8 summaries by eye. The numbers move in the third decimal when the
|
||||
gateway is under load; the committed `expected/` file plus an explicit
|
||||
tolerance is the only version of this that stays honest over months.
|
||||
- Treating a changed baseline as a corpus problem. If update-rate shifts, first
|
||||
confirm the gateway model has not changed underneath — `reasoning` and the 3B
|
||||
controller are both moving targets, and misattributing that to this phase burns
|
||||
a day.
|
||||
- Skipping assertion 11 because M5 is not built. The export *shape* is checkable
|
||||
now, and discovering R in the training corpus during M5.3 means re-running an
|
||||
expensive labelling pass.
|
||||
|
||||
---
|
||||
|
||||
Background: [DESIGN.md](../DESIGN.md) — reference corpora, the tier model · [M1.8](M1.8-m1-gate.md) · [M4.2](M4.2-derived-filter.md)
|
||||
@@ -0,0 +1,115 @@
|
||||
# M3.7.3 — `GET /memory/skills?task=` — match a subset to the work
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Phase | M3.7 — Tool context |
|
||||
| Size | M — 1–3 days |
|
||||
| Status | ⬜ Not started |
|
||||
| Flags | — |
|
||||
| Spec | inlined below |
|
||||
| Blocks | M3.7.6 |
|
||||
| Depends | M3.5.5, M3.2, M2.1 |
|
||||
|
||||
## Goal
|
||||
|
||||
Given a task, return the few skills that apply, so the orchestrator stops cloning
|
||||
the same static list for every piece of work.
|
||||
|
||||
## Facts (inlined — no spec read needed)
|
||||
|
||||
```
|
||||
GET /memory/skills?task=fix+the+kubectl+parsing+in+the+pod+debugger&project=homelab
|
||||
→ 200 [{"name":"infra-root-causes","score":0.81,
|
||||
"matched_on":"when_to_use","when_to_use":"When troubleshooting cluster…"}]
|
||||
```
|
||||
|
||||
**Match on `description` and `when_to_use`, never on the body.** Anthropic's own
|
||||
skill guidance, encoded in the installed `grafana-core:skill-authoring` rubric,
|
||||
makes `description` the field that decides whether a skill fires. Bodies are long,
|
||||
full of example output, and match everything — a skill whose body mentions
|
||||
`kubectl` in passing would be selected for every Kubernetes task. Matching the
|
||||
field the author wrote *for this purpose* also gives authors a lever they can
|
||||
reason about.
|
||||
|
||||
**Embed the metadata, rerank the shortlist.** Same two-stage shape as `mem query`
|
||||
(M3.3): embed `description + when_to_use`, cosine-recall a shortlist, then rerank
|
||||
against the task text with `bge-reranker-base` (M3.2). The corpus is small enough
|
||||
that recall could be exhaustive, but the reranker is what separates "mentions
|
||||
Kubernetes" from "is about diagnosing a failing pod".
|
||||
|
||||
**Empty is a valid answer and must stay cheap.** Most tasks match no skill. The
|
||||
endpoint returns `[]`, not the closest thing it found, and the caller proceeds
|
||||
with tools and knowledge alone. A floor applies here for the same reason it does
|
||||
in M3.6.5: a plausible-but-wrong skill actively steers the implementer.
|
||||
|
||||
**`_drafts/` stays excluded.** M3.5.5's rule is unchanged and load-bearing —
|
||||
matching must not become a side channel that loads an unpromoted skill.
|
||||
|
||||
**Deterministic ties.** Two skills at the same score sort by name, so an
|
||||
orchestrator that caches on the response is not invalidated by rank flapping
|
||||
between identical requests.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Extend the M3.5.5 handler with `?task=` and `?limit=` (default 3).
|
||||
2. Build the match index over `description + when_to_use` for promoted skills.
|
||||
3. Recall then rerank against the task text; apply the score floor.
|
||||
4. Return `score` and `matched_on` so a bad match is diagnosable without a rerun.
|
||||
5. Rebuild the index on skill promotion; no restart required.
|
||||
6. `?task=` absent keeps the existing full-catalog behaviour exactly.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- A Kubernetes debugging task matches the infra skill; an unrelated task does not.
|
||||
- Draft skills never appear.
|
||||
- No match returns `[]` with 200.
|
||||
- Omitting `task` returns the full catalog, byte-identical to today.
|
||||
- Equal scores order deterministically.
|
||||
|
||||
## Verify
|
||||
|
||||
**Harness:** vault fixture with 6 promoted skills across distinct domains plus 2
|
||||
drafts. Live reranker for scoring; deterministic embedder elsewhere.
|
||||
|
||||
**Integration test** — `tests/it_skill_matching.rs`:
|
||||
1. `a1_relevant_match` — a pod-debugging task returns the infra skill first.
|
||||
2. `a2_irrelevant_no_match` — "update the README changelog" returns `[]`.
|
||||
3. `a3_drafts_excluded` — a task whose text matches a draft's description
|
||||
verbatim returns `[]`.
|
||||
4. `a4_body_not_matched` — a skill whose *body* mentions `kubectl` but whose
|
||||
description is about something else is not returned for a `kubectl` task.
|
||||
This is the assertion that proves the field restriction.
|
||||
5. `a5_no_task_unchanged` — omit `task`; assert byte-identical to M3.5.5's
|
||||
existing fixture output.
|
||||
6. `a6_floor_applies` — a weakly-related task returns `[]` rather than the
|
||||
best-of-bad.
|
||||
7. `a7_deterministic_ties` — two identically-described skills; assert stable
|
||||
name-ordered output across 10 calls.
|
||||
8. `a8_reranker_reorders` — capture pre- and post-rerank order; assert they
|
||||
differ on at least one fixture task, proving the reranker is wired.
|
||||
9. `a9_promotion_visible` — promote a draft, re-query without restart; assert it
|
||||
is now matchable.
|
||||
|
||||
**Command:** `cargo test -p mem-api skill_matching`
|
||||
|
||||
**False pass:**
|
||||
- Fixtures whose descriptions share no vocabulary. Any embedder separates
|
||||
unrelated topics; assertion 4 needs a deliberate body/description conflict, and
|
||||
assertion 6 needs a genuinely borderline task, or both pass with a keyword
|
||||
`LIKE`.
|
||||
- Asserting only that the right skill is *present*. Returning all 6 sorted also
|
||||
contains the right one; assert the length and the floor.
|
||||
|
||||
## Traps
|
||||
|
||||
- Indexing skill bodies "for better recall". It inverts the design: bodies are
|
||||
where every skill looks alike, and the author's `description` stops being the
|
||||
control surface it was written to be.
|
||||
- Tuning the floor against the same fixtures used to assert matching. It converges
|
||||
on a threshold that fits six skills and fails on sixty; hold out tasks.
|
||||
- Rebuilding the index per request. It is small, but this endpoint sits in the
|
||||
path of every task the orchestrator runs.
|
||||
|
||||
---
|
||||
|
||||
Background: [DESIGN.md](../DESIGN.md) — tool context, skills · [M3.5.5](M3.5.5-skills-endpoint.md)
|
||||
@@ -0,0 +1,183 @@
|
||||
# M3.7.4 — `/memory/context` — three-tier lookup
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Phase | M3.7 — Tool context |
|
||||
| Size | M — 1–3 days |
|
||||
| Status | ⬜ Not started |
|
||||
| Flags | — |
|
||||
| Spec | inlined below |
|
||||
| Blocks | M3.7.6 |
|
||||
| Depends | M3.7.3, M3.7.7, M3.7.8, M3.5.3, M3.6.5 |
|
||||
|
||||
## Goal
|
||||
|
||||
One call that answers “what do we already know about this failure, tool or task”,
|
||||
cheapest tier first, and says which tier the answer came from.
|
||||
|
||||
## Existing code to build on
|
||||
|
||||
**`crates/mem-core/src/lesson.rs`** already implements the tier-1 and tier-2 lookup pattern:
|
||||
|
||||
| Function | What it does | Reuse plan |
|
||||
|---|---|---|
|
||||
| `lookup(sig, lessons, floor)` | Exact hash match (tier 1) then trigram similarity (tier 2) with abstention floor | **Extend**: add vector search (M2.1) as tier 2, keep trigrams as offline fallback |
|
||||
| `Tier` enum (`Exact`, `Similar(f32)`) | Tier labeling | **Extend**: add `Reference` variant for tier 3 |
|
||||
| `Hit` struct | `{ lesson, tier }` | **Extend**: generalize from `Lesson` to `MemoryNode` |
|
||||
| `similarity(a, b)` | Jaccard over character trigrams | **Keep** as fallback when embeddings unavailable |
|
||||
| `render_injection(hit, max_chars)` | Capped injection text | **Reuse** for budget management |
|
||||
|
||||
**`crates/mem-cli/src/lessons_cmd.rs`** already implements:
|
||||
|
||||
| Command | What it does | Reuse plan |
|
||||
|---|---|---|
|
||||
| `mem lookup --tool T --file F` | CLI tier-based lookup with floor | **Model for** the HTTP endpoint |
|
||||
|
||||
## Files
|
||||
|
||||
| Action | Path |
|
||||
|---|---|
|
||||
| **Exists** | `crates/mem-core/src/lesson.rs` — `lookup()`, `Tier`, `Hit`, `similarity()` |
|
||||
| **Exists** | `crates/mem-cli/src/lessons_cmd.rs` — `mem lookup` CLI |
|
||||
| Create | HTTP endpoint in `mem-api` crate (M3.5.1 server) |
|
||||
| Modify | `crates/mem-core/src/lesson.rs` — extend `Tier` enum with `Reference` variant |
|
||||
| Create | `tests/it_context_endpoint.rs` — integration tests (12 assertions) |
|
||||
|
||||
## Facts (inlined — no spec read needed)
|
||||
|
||||
```
|
||||
POST /memory/context
|
||||
{ "tool": "github-actions",
|
||||
"signature_source": "<50KB run log>",
|
||||
"project": "homelab", "scope": "all-projects", "budget": 6000 }
|
||||
|
||||
→ 200 {
|
||||
"tier": 1,
|
||||
"lessons": [
|
||||
{"tier":1,"level":"L1","seen_count":3,"last_seen":"2026-07-02",
|
||||
"text":"peer dep conflict @types/react 18 vs 19; npm ci --legacy-peer-deps
|
||||
unblocks, real fix is pinning in overrides",
|
||||
"parents":[{"level":"L0","source":"pi:…"}]},
|
||||
{"tier":2,"level":"L2","score":0.71,"matched_kind":"symptom","text":"…"}
|
||||
],
|
||||
"skills": [{"name":"ci-triage","score":0.77}],
|
||||
"budget": {"limit":6000,"used":2140,"dropped":[]}
|
||||
}
|
||||
```
|
||||
|
||||
Three accepted inputs, any combination: `tool`, `task`, `signature_source`. At
|
||||
least one is required; `signature_source` without `tool` is allowed and the tool
|
||||
is inferred by the extractor's rule match.
|
||||
|
||||
**Tiers, cheapest first:**
|
||||
|
||||
| Tier | Mechanism | Meaning |
|
||||
|---|---|---|
|
||||
| 1 | `sig_sha` primary-key hit on `failure_signature` | this exact failure happened here before |
|
||||
| 2 | vector over `kind='symptom'` then `kind='text'`, reranked | something similar happened |
|
||||
| 3 | R reference corpus | nobody here has hit this; here are the docs |
|
||||
|
||||
Tier 1 does not short-circuit tiers 2 and 3 — it *leads*. An exact hit plus two
|
||||
related memories is a better answer than an exact hit alone, and the tiers cost
|
||||
milliseconds relative to the caller's own inference.
|
||||
|
||||
**`tier` is in the response and this matters.** The caller, and the human reading
|
||||
its output, must be able to tell "we hit this exact error in July" from "here is
|
||||
what the manual says". Presenting tier 3 in the register of tier 1 is how a
|
||||
retrieval system becomes untrustworthy.
|
||||
|
||||
**Ordering is by tier, then rerank, and never by raw score.** A cheatsheet is
|
||||
written to match the phrasing of a question and will routinely out-score the
|
||||
terse memory that actually solved it. Precedence is a rule: tier 1 > L1/L2 > R.
|
||||
|
||||
**Scope defaults differ by tier.** Signature and symptom lookups federate across
|
||||
projects — `ERESOLVE` is not homelab-specific — while task-shaped queries stay
|
||||
project-scoped unless asked otherwise. Project match becomes a rank boost, not a
|
||||
filter.
|
||||
|
||||
**Superseded memories are excluded, not demoted.** A lesson about Kong config is
|
||||
wrong now, not merely old. M2.4 filters them at the repository; this endpoint
|
||||
surfaces the successor if one is linked.
|
||||
|
||||
**Budget, fixed truncation order:** drop R, then trim tier-2 results toward the
|
||||
floor, then drop skills. Tier-1 hits are never dropped — they are the smallest and
|
||||
most valuable content in the response.
|
||||
|
||||
**Legs degrade independently.** A skills timeout returns `"skills":[]` with a
|
||||
`degraded` note and a 200. There is no leg whose failure justifies a 5xx here; a
|
||||
thinner answer beats no answer when someone is mid-incident.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Route in the M3.5.1 server; accept `tool`, `task`, `signature_source`.
|
||||
2. If `signature_source` present, extract and normalise (M3.7.7), then tier 1.
|
||||
3. Tier 2 concurrently: symptom-vector search, then text-vector, merge, rerank.
|
||||
4. Tier 3 only if tiers 1–2 leave budget unfilled.
|
||||
5. Skills leg (M3.7.3) concurrently with tier 2.
|
||||
6. Order by tier, apply precedence, apply budget, record drops.
|
||||
7. On tier-1 hit, append an occurrence record to the log so `seen_count` grows.
|
||||
8. One structured log line: tiers fired, latencies, scores, drops.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- A previously-seen failure returns `tier: 1` with `seen_count` ≥ 2.
|
||||
- An unseen but similar failure returns tier 2 with `matched_kind: "symptom"`.
|
||||
- A wholly unknown failure returns tier 3 and says so.
|
||||
- Tier 1 outranks a higher-scoring R result.
|
||||
- Identical requests return byte-identical bodies apart from the occurrence side
|
||||
effect.
|
||||
|
||||
## Verify
|
||||
|
||||
**Harness:** M3.5.1 test server over a store seeded with the poimen log, a
|
||||
reference corpus, promoted skills, and signatures from replayed real failures.
|
||||
Fault injection per leg.
|
||||
|
||||
**Integration test** — `tests/it_context_endpoint.rs`:
|
||||
1. `a1_tier1_exact` — replay a failure already in `failure_signature`; assert
|
||||
`tier: 1` and the correct memory.
|
||||
2. `a2_tier1_counts_occurrence` — call twice; assert `seen_count` incremented and
|
||||
an occurrence record is in the log.
|
||||
3. `a3_tier2_symptom` — a novel wording of a known incident returns tier 2 with
|
||||
`matched_kind: "symptom"`.
|
||||
4. `a4_tier2_beats_text_only` — same query with symptom vectors deleted ranks the
|
||||
correct memory lower; assert the symptom path strictly improves it.
|
||||
5. `a5_tier3_fallback` — an unknown failure returns tier 3 and no lesson claims a
|
||||
lower tier.
|
||||
6. `a6_precedence_over_score` — seed an R node that reranks above a tier-1 hit;
|
||||
assert the tier-1 hit still leads and the response exposes both raw scores.
|
||||
7. `a7_superseded_excluded` — mark a memory superseded; assert it is absent and
|
||||
its successor is present.
|
||||
8. `a8_budget_order` — shrink the budget stepwise; assert drops occur R, then
|
||||
tier 2, then skills, and that tier 1 is never dropped.
|
||||
9. `a9_skills_degrade` — inject a skills timeout; assert 200, `[]`, `degraded`.
|
||||
10. `a10_signature_without_tool` — omit `tool`; assert the extractor infers it.
|
||||
11. `a11_scope_federation` — a signature seeded under another project is found
|
||||
with `scope: all-projects` and not with `scope: project`.
|
||||
12. `a12_used_matches_actual` — tokenize the body independently; assert equality
|
||||
with `budget.used`.
|
||||
|
||||
**Command:** `cargo test -p mem-api context_endpoint`
|
||||
|
||||
**False pass:**
|
||||
- Asserting tier 1 fires without asserting tiers 2 and 3 still populate. A
|
||||
short-circuit passes assertion 1 and produces a thin answer in exactly the case
|
||||
where the most context is available.
|
||||
- Testing precedence on a fixture where the tier-1 hit also scores highest.
|
||||
Assertion 6 is only meaningful when score and tier disagree.
|
||||
- Testing degradation with an empty leg instead of a failing one. Timeouts take
|
||||
the path that 500s in production.
|
||||
|
||||
## Traps
|
||||
|
||||
- Running tier 3 unconditionally. Reference chunks are long, they fill the budget,
|
||||
and they push real memories out of a response that had better answers available.
|
||||
- Incrementing `seen_count` on a retry. The orchestrator retries activities; an
|
||||
occurrence should key on the caller's request id or the count inflates and
|
||||
`last_seen` stops meaning anything.
|
||||
- Returning tier as a label the caller has to interpret from ordering. It is a
|
||||
field; if it is implicit, every consumer reimplements the inference differently.
|
||||
|
||||
---
|
||||
|
||||
Background: [DESIGN.md](../DESIGN.md) — tool context, retrieval tiers
|
||||
@@ -0,0 +1,162 @@
|
||||
# M3.7.5 — `tool-failures` standing query — the loop that makes it improve
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Phase | M3.7 — Tool context |
|
||||
| Size | M — 1–3 days |
|
||||
| Status | 🟡 In progress — lesson derivation implemented in `lesson.rs` |
|
||||
| Flags | — |
|
||||
| Spec | inlined below |
|
||||
| Blocks | M3.7.6 |
|
||||
| Depends | M3.7.4, M1.2, M1.5 |
|
||||
|
||||
## Goal
|
||||
|
||||
Turn invocations that failed into memory that prevents them, and prove the
|
||||
prevention actually reaches the next task’s prompt.
|
||||
|
||||
## Existing code (already implemented)
|
||||
|
||||
**`crates/mem-core/src/lesson.rs`** already contains:
|
||||
|
||||
| Function | What it does | Tests |
|
||||
|---|---|---|
|
||||
| `derive_lessons(events, tool_of)` | Pairs fail→success from command events, filters opaque edits, captures resolution | `derives_lesson_from_fail_then_success`, `opaque_edits_do_not_become_a_resolution`, `failed_attempts_are_not_the_resolution`, `bare_retry_is_not_a_lesson` |
|
||||
| `tool_of_cmd(cmd)` | Infers tool name from command (npm, cargo, kubectl, etc.) | used by `derive_lessons` |
|
||||
|
||||
**`crates/mem-cli/src/lessons_cmd.rs`** already contains:
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `mem capture --cmd ... --exit ...` | Records command execution events to `~/.mem/events.jsonl` |
|
||||
| `mem resolve` | Derives lessons from events, preserves human confirmations |
|
||||
|
||||
## What remains to complete this task
|
||||
|
||||
The existing code operates on **command execution events** (individual tool invocations with exit codes). This task requires integration with the **GRU-Mem gated loop** (M1.5) which operates on full session transcripts:
|
||||
|
||||
1. **Standing query YAML** — add `tool-failures` query to `queries/<project>.yaml` with the verbatim-invocation requirement
|
||||
2. **Gate-based extraction** — the gated loop (M1.5) decides which transcript chunks contain tool-failure evidence, not `derive_lessons()` from command events
|
||||
3. **End-to-end test** — ingest a failure session, then verify `/memory/context` returns the failure ranked above docs
|
||||
4. **Orchestrator lesson ingestion** — ingest `Poimen/workflows` lesson artifacts as a source
|
||||
5. **Derived filter interaction** — verify lessons are NOT caught by M4.2’s derived filter
|
||||
|
||||
The existing `derive_lessons()` remains useful as a **complementary path** for command-level failures, while the standing query handles full-transcript extraction.
|
||||
|
||||
## Files
|
||||
|
||||
| Action | Path |
|
||||
|---|---|
|
||||
| **Exists** | `crates/mem-core/src/lesson.rs` — `derive_lessons()`, `tool_of_cmd()` |
|
||||
| **Exists** | `crates/mem-cli/src/lessons_cmd.rs` — `mem capture`, `mem resolve` |
|
||||
| Modify | `queries/<project>.yaml` — add `tool-failures` standing query |
|
||||
| Create | `tests/it_tool_failure_learning.rs` — integration tests (8 assertions) |
|
||||
|
||||
## Facts (inlined — no spec read needed)
|
||||
|
||||
```yaml
|
||||
# queries/<project>.yaml
|
||||
- id: tool-failures
|
||||
question: >
|
||||
Which tool or command invocations failed, what was the exact error,
|
||||
and what was the working alternative? Record the invocation verbatim.
|
||||
```
|
||||
|
||||
This is the only leg of the tool-context bundle that **goes through the update
|
||||
gate**, and it should. A failed `kubectl` invocation is genuine evidence about
|
||||
what happened in this project — unlike reference text (M3.6), which has no
|
||||
evidence to gate on. No bypass, no special casing, no new machinery: one standing
|
||||
question whose answers happen to be operationally useful at task time.
|
||||
|
||||
**The gate's discrimination is the feature here.** Sessions are full of commands
|
||||
that failed for uninteresting reasons — a typo the model immediately fixed, a
|
||||
transient 503. The question asks for the *working alternative*, which is what
|
||||
separates a durable lesson from noise, and the gate is what enforces it. If
|
||||
update-rate on this query runs high, the question is too permissive, not the gate.
|
||||
|
||||
**Verbatim invocation matters.** "Use the right namespace flag" is unusable. The
|
||||
memory has to carry `kubectl get pods --all` → `error: unknown flag: --all` →
|
||||
`kubectl get pods --all-namespaces`, because the next model needs the exact
|
||||
string to pattern-match against what it was about to emit.
|
||||
|
||||
**This is where the orchestrator's lessons should end up.** `Poimen/workflows`
|
||||
already generates lessons on judge rejection (`action/lessons.go`) and discards
|
||||
them at task end. Ingesting those artifacts gives this query a dense, pre-filtered
|
||||
source — failures already judged consequential by a second model.
|
||||
|
||||
**Success is measured end to end, not at L1.** An L1 memory nobody retrieves is
|
||||
worthless. The acceptance criterion is that a task mentioning the tool gets the
|
||||
failure in its `/memory/context` bundle, ranked above the cheatsheet.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Add `tool-failures` to the shipped query templates, with the verbatim
|
||||
requirement in the question text.
|
||||
2. Ingest orchestrator lesson artifacts as a source alongside session
|
||||
transcripts.
|
||||
3. Confirm no interaction with M3.6.4's manifest: lessons are project output, not
|
||||
emitted artifacts, and must not be excluded as derived.
|
||||
4. Measure update-rate for this query separately; it should sit well under the
|
||||
30% M1.8 threshold.
|
||||
5. End-to-end check: ingest a failure session, then request `/memory/context` for
|
||||
a related task and assert the failure is present and ranked above R.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- A session containing a failure-then-fix yields an L1 memory with both forms
|
||||
verbatim.
|
||||
- A session with only transient errors yields none.
|
||||
- The memory appears in `/memory/context` for a related task, above the docs.
|
||||
- Lesson artifacts are not caught by the derived filter.
|
||||
- Update-rate for this query stays under the M1.8 threshold.
|
||||
|
||||
## Verify
|
||||
|
||||
**Harness:** three fixture sessions — one clean failure-then-fix, one transient
|
||||
503 with no lesson, one where the model tried three wrong forms before succeeding.
|
||||
Live gateway for the gate decisions.
|
||||
|
||||
**Integration test** — `tests/it_tool_failure_learning.rs`:
|
||||
1. `a1_failure_becomes_memory` — fixture 1 yields an L1 under `tool-failures`
|
||||
containing both the failing and the working invocation, verbatim.
|
||||
2. `a2_transient_rejected` — fixture 2 produces no L1. This is the assertion that
|
||||
proves the gate is discriminating rather than recording every non-zero exit.
|
||||
3. `a3_multi_attempt_keeps_final` — fixture 3's memory names the working form,
|
||||
not merely the last error.
|
||||
4. `a4_reaches_the_bundle` — after ingest, `GET /memory/context?task=…kubectl…`
|
||||
contains the memory.
|
||||
5. `a5_outranks_documentation` — in that same bundle, assert it sorts above the
|
||||
R cheatsheet section covering the same command.
|
||||
6. `a6_lessons_not_derived` — ingest a lesson artifact; assert no
|
||||
`derived_excluded` event fires for it.
|
||||
7. `a7_update_rate_bounded` — update-rate for this query is under 0.30, reported
|
||||
alongside the other standing queries.
|
||||
8. `a8_provenance_resolves` — the memory's parents resolve to the L0 span
|
||||
containing the actual error text.
|
||||
|
||||
**Command:** `cargo test --workspace tool_failure -- --ignored --nocapture`
|
||||
|
||||
**False pass:**
|
||||
- Asserting only `a1`. A gate that accepts every chunk also produces the right
|
||||
memory for fixture 1; `a2` is the one that distinguishes a filter from a
|
||||
recorder, and it must run in the same binary.
|
||||
- Stopping at L1. Assertions 4 and 5 are the task — an L1 that never reaches a
|
||||
prompt has changed nothing about how the implementer behaves.
|
||||
- Fixtures written by the same model that will be judged on them. Use real
|
||||
session transcripts; synthetic failures are unnaturally clean and the gate
|
||||
accepts them at a rate real sessions will not reproduce.
|
||||
|
||||
## Traps
|
||||
|
||||
- Writing the question to ask for "errors". Every tool result containing the word
|
||||
error becomes evidence, update-rate climbs, and M1.8 goes red for reasons that
|
||||
look unrelated to this task. The working-alternative clause is what bounds it.
|
||||
- Ingesting lessons without a project key. They arrive from the orchestrator, not
|
||||
from a session with a `cwd`, so project resolution has to be explicit or they
|
||||
land in the wrong memory.
|
||||
- Treating a high update-rate here as success. It means the question is loose;
|
||||
the paper's failure mode is a memory that accepts everything.
|
||||
|
||||
---
|
||||
|
||||
Background: [DESIGN.md](../DESIGN.md) — tool context, standing queries · [M1.8](M1.8-m1-gate.md)
|
||||
@@ -0,0 +1,124 @@
|
||||
# M3.7.6 — M3.7 composition gate
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Phase | M3.7 — Tool context |
|
||||
| Size | M — 1–3 days |
|
||||
| Status | ⬜ Not started |
|
||||
| Flags | gate |
|
||||
| Spec | inlined below |
|
||||
| Blocks | all of M3.7 |
|
||||
| Depends | M3.7.3, M3.7.4, M3.7.5, M3.7.7, M3.7.8 |
|
||||
|
||||
## Goal
|
||||
|
||||
Prove the lookup answers real failures from history rather than handing back
|
||||
documentation, and that adding it changed nothing upstream.
|
||||
|
||||
## Facts (inlined — no spec read needed)
|
||||
|
||||
The phase's claim is narrow and testable: **given a failure this project has
|
||||
solved before, the lookup returns the fix.** Everything else is machinery in
|
||||
service of that.
|
||||
|
||||
**The measurement is a replay, not an A/B.** Collect real failures with known
|
||||
resolutions from session history and CI. Hold out half. Ingest the first half,
|
||||
then replay *all* of them against `/memory/context` and score:
|
||||
|
||||
| Signal | What it proves |
|
||||
|---|---|
|
||||
| tier-1 hit rate on ingested failures | signature normalisation actually stabilises (M3.7.7) |
|
||||
| tier-2 recall on held-out failures | symptom projections generalise beyond exact repeats (M3.7.8) |
|
||||
| tier-3 rate on ingested failures | how often the system falls back to docs when it should have known |
|
||||
|
||||
A high tier-3 rate on failures already in the corpus is the phase failing, and it
|
||||
is the number to watch. It means retrieval exists and does not fire.
|
||||
|
||||
**No dependency on the orchestrator.** The consumer is any HTTP client — `pi`,
|
||||
curl, an MCP call. Nothing here requires `Poimen/workflows` to execute tool calls,
|
||||
which is what made the earlier version of this gate unrunnable.
|
||||
|
||||
**Upstream must be undisturbed.** This phase adds a standing query
|
||||
(`tool-failures`), a second vector kind, and two tables. Each can perturb things
|
||||
that were green: update-rate for M1.8, recall width for M3.6.6, rebuild parity for
|
||||
M2.8. Re-assert all three.
|
||||
|
||||
**Cost belongs in the result.** The lookup sits in front of real work. Report p50
|
||||
and p95 for each tier separately — a 900ms tier-2 is a different product than a
|
||||
40ms tier-1, and the averages hide it.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Assemble ≥40 real failures with known resolutions across ≥4 tools; commit the
|
||||
set before running anything.
|
||||
2. Split 50/50 into ingested and held-out.
|
||||
3. Ingest the first half through the normal path — sessions, `tool-failures`
|
||||
standing query, gate, symptom projections, signatures.
|
||||
4. Replay all 40 against `/memory/context`; record tier, rank of the correct
|
||||
answer, latency.
|
||||
5. Re-run M1.8, M2.8 and M3.6.6.
|
||||
6. Emit `expected/m3.7-gate.txt` with per-tool tier rates and latency
|
||||
percentiles; commit it, same rule as M1.8.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Tier-1 hit rate on ingested failures ≥ 0.80.
|
||||
- Tier-2 returns the correct memory in the top 3 for ≥ 0.50 of held-out failures.
|
||||
- Tier-3 rate on ingested failures ≤ 0.10.
|
||||
- M1.8, M2.8 and M3.6.6 unchanged except for the added standing query.
|
||||
- Tier-1 p95 under 50ms; tier-2 p95 under 500ms.
|
||||
|
||||
## Verify
|
||||
|
||||
**Harness:** live gateway, real database, the committed failure set. Long-running,
|
||||
`#[ignore]` by default, same posture as M1.8.
|
||||
|
||||
**Integration test** — `tests/it_m3_7_gate.rs`:
|
||||
1. `a1_tier1_hit_rate` — replay the ingested half; assert ≥ 0.80 return tier 1,
|
||||
print per-tool.
|
||||
2. `a2_tier3_rate_bounded` — on that same half, assert ≤ 0.10 fall through to
|
||||
tier 3. This is the "retrieval exists but never fires" detector.
|
||||
3. `a3_heldout_recall` — the held-out half; assert the correct memory is in the
|
||||
top 3 for ≥ 0.50, proving symptom projections generalise rather than memorise.
|
||||
4. `a4_symptom_ablation` — delete `kind='symptom'` vectors, re-run `a3`; assert
|
||||
recall drops measurably. Without this, `a3` could be satisfied by the text
|
||||
vector alone and M3.7.8 would be dead weight.
|
||||
5. `a5_signature_stability` — for failures appearing more than once in the set,
|
||||
assert every occurrence produced the same `sig_sha`.
|
||||
6. `a6_precedence_held` — across the whole replay, assert no response placed an R
|
||||
result above a tier-1 or tier-2 lesson.
|
||||
7. `a7_m1_8_unchanged` — re-run M1.8; per-query numbers match the committed
|
||||
baseline, `tool-failures` the only addition.
|
||||
8. `a8_m2_8_rebuild` — drop and rebuild with vectors, signatures and supersede
|
||||
rows present; assert byte-identical.
|
||||
9. `a9_m3_6_6_still_green` — re-run the M3.6 gate in full.
|
||||
10. `a10_latency_by_tier` — p50/p95 per tier; assert the two thresholds.
|
||||
11. `a11_no_orchestrator_dependency` — run the whole gate with `Poimen/workflows`
|
||||
absent; assert it completes.
|
||||
|
||||
**Command:** `cargo test --workspace m3_7_gate -- --ignored --nocapture`
|
||||
|
||||
**False pass:**
|
||||
- Replaying the ingested half only. It measures memorisation; `a3` on held-out
|
||||
data is the one that says anything about a failure you have not seen before.
|
||||
- Skipping `a4`. A symptom index that is empty, or full of paraphrase, passes
|
||||
every other assertion here — the ablation is the only proof it contributes.
|
||||
- Counting a tier-1 hit without checking the returned memory is the *right* one.
|
||||
A signature collision produces a confident wrong answer, which is worse than
|
||||
tier 3.
|
||||
- Building the failure set from failures the system already handles well.
|
||||
Fix the set first, commit it, then run.
|
||||
|
||||
## Traps
|
||||
|
||||
- Curating resolutions after seeing what retrieval returns. The known-good answer
|
||||
for each failure has to be written down before the first replay.
|
||||
- Reading a low tier-1 rate as a retrieval problem. It is almost always
|
||||
normalisation (M3.7.7); check `mem sig explain` on the misses before touching
|
||||
anything downstream.
|
||||
- Letting the ingested half leak into the held-out half through near-duplicate
|
||||
failures. Split by incident, not by log file.
|
||||
|
||||
---
|
||||
|
||||
Background: [DESIGN.md](../DESIGN.md) — tool context · [M1.8](M1.8-m1-gate.md) · [M3.6.6](M3.6.6-m3.6-gate.md)
|
||||
@@ -0,0 +1,172 @@
|
||||
# M3.7.7 — Failure signature extraction and normalisation
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Phase | M3.7 — Tool context |
|
||||
| Size | M — 1–3 days |
|
||||
| Status | 🟡 In progress — core implemented in `lesson.rs` |
|
||||
| Flags | — |
|
||||
| Spec | inlined below |
|
||||
| Blocks | M3.7.6 |
|
||||
| Depends | M0.2, M2.3 |
|
||||
|
||||
## Goal
|
||||
|
||||
Reduce 50KB of failure output to a short string that is byte-identical the next
|
||||
time the same thing breaks.
|
||||
|
||||
## Existing code (already implemented)
|
||||
|
||||
**`crates/mem-core/src/lesson.rs`** (871 lines) already contains:
|
||||
|
||||
| Function | Lines | What it does | Tests |
|
||||
|---|---|---|---|
|
||||
| `extract(tool, output)` | 200–260 | Rule-based signature extraction per tool | `same_failure_different_runs_same_hash`, `different_failures_differ`, `cascade_lines_are_skipped`, `code_declaration_does_not_split_a_failure`, `tool_is_part_of_identity`, `unknown_tool_falls_back` |
|
||||
| `normalise(raw)` | 60–170 | Strips ANSI, timestamps, paths, shas, line:col, durations, addresses | `strips_ansi`, `normalises_volatiles_but_keeps_exit_codes`, `error_lines_still_keep_basenames` |
|
||||
| `normalise_cmd(cmd)` | 179–195 | Harsher normalisation for commands (drops basenames) | `cmd_key_ignores_temp_file_names` |
|
||||
| `strip_ansi(s)` | 30–50 | ANSI SGR sequence removal | `strips_ansi` |
|
||||
| `markers(tool)` | — | Per-tool error line markers: npm, cargo, go, kubectl, gha, docker, terraform | — |
|
||||
| `is_cascade(line)` | — | Suppresses consequence lines (`##[error]Process completed...`) | `cascade_lines_are_skipped` |
|
||||
| `is_code_declaration(line)` | — | Handles `npm ERR! code ERESOLVE` prefix dedup | `code_declaration_does_not_split_a_failure` |
|
||||
| `Signature` struct | 199–208 | `{ tool, raw, normalised, sig_sha, rule }` | — |
|
||||
|
||||
All 10 relevant unit tests pass: `cargo test -p mem-core -- lesson`
|
||||
|
||||
## What remains to complete this task
|
||||
|
||||
1. **`mem sig explain` CLI command** — not yet in `main.rs`
|
||||
2. **`fixtures/failures/` directory** — real captured logs from different runs (hand-written fixtures exist as inline test strings only)
|
||||
3. **Integration test file** `tests/it_signature.rs` — the 9 assertions listed in Verify below (current tests are unit tests inside `lesson.rs`, not integration tests)
|
||||
4. **Latency test (a7)** — 50KB log extracts in under 50ms
|
||||
5. **`mem sig explain` output** that names the matching rule (a9)
|
||||
|
||||
## Files
|
||||
|
||||
| Action | Path |
|
||||
|---|---|
|
||||
| **Exists** | `crates/mem-core/src/lesson.rs` — core logic already here |
|
||||
| Modify | `crates/mem-cli/src/main.rs` — add `Commands::Sig { Explain }` subcommand |
|
||||
| Create | `tests/it_signature.rs` — integration tests (9 assertions) |
|
||||
| Create | `fixtures/failures/npm-run-a.txt`, `npm-run-b.txt`, `npm-different.txt` — real logs |
|
||||
| Create | `fixtures/failures/cargo-run-a.txt`, etc. — per-tool pairs |
|
||||
|
||||
## Facts (inlined — no spec read needed)
|
||||
|
||||
```
|
||||
in: <14000 lines of GitHub Actions log>
|
||||
out: { tool: "github-actions",
|
||||
signature: "npm ERR! ERESOLVE unable to resolve dependency tree",
|
||||
sig_sha: "7f3a…",
|
||||
context: { job: "build", step: "npm ci", exit_code: 1 } }
|
||||
```
|
||||
|
||||
**Normalisation is the whole task.** Two runs of the same failure differ in run
|
||||
id, timestamps, durations, temp paths, container ids, commit shas, line numbers
|
||||
and memory addresses. Every one of those must be stripped or the hash never
|
||||
matches twice and tier 1 of the lookup never fires — the feature silently
|
||||
degrades to vector search and nobody notices, because vector search still returns
|
||||
*something*.
|
||||
|
||||
Substitution list, applied before hashing:
|
||||
|
||||
```
|
||||
/home/runner/work/<org>/<repo>/… -> <WORKSPACE>/…
|
||||
2026-08-21T10:02:11.482Z -> <TS>
|
||||
[0-9a-f]{7,40} -> <SHA>
|
||||
:[0-9]+:[0-9]+ -> :<LINE>:<COL>
|
||||
0x[0-9a-f]+ -> <ADDR>
|
||||
took 4m21s / in 132ms -> <DUR>
|
||||
/tmp/[A-Za-z0-9]+ -> <TMP>
|
||||
```
|
||||
|
||||
**Deterministic first, model second.** Most tools have a findable error line —
|
||||
`npm ERR!`, `error:`, `Error:`, `FAILED`, a non-zero exit with the last stderr
|
||||
block. Extract with rules per tool and fall back to the 3B controller only when
|
||||
the rules find nothing. A model in the hot path of every lookup is both slow and
|
||||
non-deterministic, and non-determinism here means the same failure hashes two
|
||||
ways.
|
||||
|
||||
**One signature, not a fingerprint set.** Take the *first* error that is not a
|
||||
consequence of an earlier one. Cascading failures produce twenty error lines and
|
||||
matching on the last one keys the memory to a symptom of a symptom.
|
||||
|
||||
**Keep `raw` alongside `sig_sha`.** The normalised form is unreadable to a human
|
||||
and the display path needs the original. Store both; hash only the normalised.
|
||||
|
||||
**Unknown tools must degrade, not fail.** No rule set for a tool means: take the
|
||||
last non-empty stderr block, normalise, hash. A worse signature is still a
|
||||
signature, and a lookup that 500s because the tool is unrecognised is useless in
|
||||
exactly the situation someone needs it.
|
||||
|
||||
## Steps
|
||||
|
||||
1. `mem-core::signature` — `extract(tool, raw) -> Option<Signature>`.
|
||||
2. Rule sets for `github-actions`, `kubectl`, `npm`, `cargo`, `go`, `docker`;
|
||||
a generic fallback for everything else.
|
||||
3. Normalisation pipeline as above, ordered and documented; each substitution
|
||||
named so a mismatch is debuggable.
|
||||
4. `sig_sha = sha256(tool + "\n" + normalised)` — tool is part of identity, since
|
||||
`exit status 1` means different things in different tools.
|
||||
5. Cascade suppression: prefer the earliest error line not preceded by another.
|
||||
6. `mem sig explain <file>` — print extracted signature, normalised form, hash
|
||||
and which rule fired. This is the debugging surface for the whole tier.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- The same failure from two different runs produces the same `sig_sha`.
|
||||
- Two genuinely different failures from the same tool produce different hashes.
|
||||
- An unrecognised tool still produces a signature.
|
||||
- Extraction on a 50KB log completes in under 50ms with no model call.
|
||||
- `mem sig explain` names the rule that fired.
|
||||
|
||||
## Verify
|
||||
|
||||
**Harness:** `fixtures/failures/` — for each of six tools, **two real logs of the
|
||||
same failure from different runs**, plus one log of a different failure from the
|
||||
same tool. Real captured output, not hand-written.
|
||||
|
||||
**Integration test** — `tests/it_signature.rs`:
|
||||
1. `a1_same_failure_same_hash` — for each tool, the two same-failure logs produce
|
||||
identical `sig_sha`. This is the assertion the tier depends on.
|
||||
2. `a2_different_failure_different_hash` — the third log hashes differently.
|
||||
3. `a3_normalisation_removes_volatiles` — assert the normalised string contains
|
||||
no timestamp, path, sha, line number or duration, by regex.
|
||||
4. `a4_cascade_picks_first` — a log with a root error followed by five induced
|
||||
ones yields the root.
|
||||
5. `a5_unknown_tool_fallback` — a log from an unlisted tool yields a signature and
|
||||
names the generic rule.
|
||||
6. `a6_no_model_calls` — run under a transport that panics on request; assert
|
||||
every fixture extracts.
|
||||
7. `a7_latency` — 50KB log extracts in under 50ms.
|
||||
8. `a8_tool_in_identity` — the same normalised text under two different tools
|
||||
hashes differently.
|
||||
9. `a9_explain_names_rule` — `mem sig explain` output identifies the matching
|
||||
rule for each fixture.
|
||||
|
||||
**Command:** `cargo test -p mem-core signature`
|
||||
|
||||
**False pass:**
|
||||
- Fixtures generated by re-running the same command in the same directory at
|
||||
nearly the same time. Paths and timestamps barely differ and assertion 1 passes
|
||||
with normalisation disabled. The two logs must come from genuinely different
|
||||
runs — different machine, different day, different workspace.
|
||||
- Asserting only 1 and 2. A hash of the whole log satisfies 2 and fails 1; a
|
||||
constant satisfies 1 and fails 2. Both are required, and 3 is what proves the
|
||||
mechanism rather than the outcome.
|
||||
- Hand-written fixture logs. They omit exactly the volatile noise the task
|
||||
exists to strip.
|
||||
|
||||
## Traps
|
||||
|
||||
- Normalising too hard. Replacing every number makes `exit status 1` and
|
||||
`exit status 137` collide, and OOM stops being distinguishable from a test
|
||||
failure. Numbers that are part of the error's meaning must survive.
|
||||
- Anchoring on the last line. It is usually `##[error]Process completed with exit
|
||||
code 1`, which is identical across every failure GitHub Actions ever produced.
|
||||
- Letting the fallback silently handle a tool that has a rule set. If a rule set
|
||||
exists and does not match, that is a signal the tool changed its output format;
|
||||
report it rather than quietly degrading.
|
||||
|
||||
---
|
||||
|
||||
Background: [DESIGN.md](../DESIGN.md) — tool context, retrieval tiers
|
||||
@@ -0,0 +1,141 @@
|
||||
# M3.7.8 — Symptom projection: make an answer findable from an error
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Phase | M3.7 — Tool context |
|
||||
| Size | M — 1–3 days |
|
||||
| Status | ⬜ Not started |
|
||||
| Flags | — |
|
||||
| Spec | inlined below |
|
||||
| Blocks | M3.7.6 |
|
||||
| Depends | M3.7.7, M2.4, M1.5 |
|
||||
|
||||
## Goal
|
||||
|
||||
Give every memory a second vector describing the failures it would explain, so a
|
||||
stack trace can find an answer written in prose.
|
||||
|
||||
## Facts (inlined — no spec read needed)
|
||||
|
||||
The asymmetry this exists to fix:
|
||||
|
||||
```
|
||||
L1 memory (how it is written):
|
||||
"Requests over 10KB failed because Kong buffered the whole body before
|
||||
proxying; resolved with proxy-body-size: 0 on the ingress."
|
||||
|
||||
Query (how it arrives):
|
||||
"413 Request Entity Too Large" + a curl trace
|
||||
```
|
||||
|
||||
Same incident. Embedded with the same model and compared by cosine, they are
|
||||
mediocre neighbours — one is an explanation, the other is a symptom. This is the
|
||||
main reason retrieval that looks correct in a unit test disappoints in use.
|
||||
|
||||
**Fix at write time, not read time.** When the gated loop accepts a memory,
|
||||
generate a short *symptom projection* — the errors, messages and observable
|
||||
behaviour this memory would explain — and embed that as a second vector:
|
||||
|
||||
```
|
||||
symptom projection for the memory above:
|
||||
"413 Request Entity Too Large; large POST bodies rejected at the ingress;
|
||||
uploads over 10KB fail while small ones succeed; nginx/Kong body buffer limit"
|
||||
```
|
||||
|
||||
The alternative, HyDE, generates a hypothetical answer per *query* and puts an
|
||||
LLM call on every lookup. Writes are rare here — the gate keeps acceptance under
|
||||
30% by design — and lookups should be fast, so paying once at write is the right
|
||||
side of that trade.
|
||||
|
||||
**It is a projection, so it obeys the projection rules.** Regenerated by
|
||||
`mem rebuild --from-log`, never authoritative, and byte-identical on replay —
|
||||
which means the generation call must be deterministic: temperature 0, pinned
|
||||
prompt, and the model id recorded in the log record so a model change is visible
|
||||
as a rebuild difference rather than silent drift.
|
||||
|
||||
**Only L1 and L2 get one.** L0 is raw evidence already phrased as symptoms; R is
|
||||
documentation and its headings already read like problems. Generating projections
|
||||
for those doubles the index for no gain.
|
||||
|
||||
**Empty is allowed.** Not every memory explains a failure — an architectural
|
||||
decision has no symptoms. The controller returns nothing, no vector is written,
|
||||
and the memory remains findable by its text vector alone. A model that invents
|
||||
symptoms for a memory that has none pollutes the symptom index with plausible
|
||||
nonsense, which is worse than a smaller index.
|
||||
|
||||
**Signature linking.** Where the L0 evidence behind an accepted memory contains a
|
||||
parseable failure (M3.7.7), write a `failure_signature` row pointing at the L1.
|
||||
That is what turns the next occurrence into an exact-match tier-1 hit instead of
|
||||
a vector search.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Prompt template `prompts/symptom.tmpl` — memory text in, symptom lines out,
|
||||
explicit "return nothing if this describes no failure".
|
||||
2. Hook into the gated loop after a memory is accepted; L1 and L2 only.
|
||||
3. Temperature 0; record model id and prompt sha on the log record.
|
||||
4. Embed and write `memory_vector(kind='symptom')`.
|
||||
5. Extract signatures from the memory's L0 parents; write `failure_signature`
|
||||
rows keyed to the memory.
|
||||
6. `mem rebuild --from-log` regenerates projections; assert stability.
|
||||
7. `mem symptoms show <sha>` prints the projection for inspection.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- An accepted L1 that explains a failure gains a symptom vector.
|
||||
- A memory describing a decision gains none.
|
||||
- Symptom text retrieves its memory from a raw error string that shares no
|
||||
vocabulary with the memory text.
|
||||
- Rebuild regenerates projections byte-identically.
|
||||
- Signature rows link to the right memory.
|
||||
|
||||
## Verify
|
||||
|
||||
**Harness:** fixture memories — five that explain failures, three that do not —
|
||||
plus raw error strings for the five, deliberately worded with no vocabulary
|
||||
overlap with the memory text.
|
||||
|
||||
**Integration test** — `tests/it_symptom_projection.rs`:
|
||||
1. `a1_projection_generated` — the five failure memories each gain a
|
||||
`kind='symptom'` vector.
|
||||
2. `a2_no_projection_for_non_failure` — the three others gain none.
|
||||
3. `a3_retrieval_by_symptom` — searching `kind='symptom'` with each raw error
|
||||
string returns its memory first. This is the assertion the task exists for.
|
||||
4. `a4_beats_text_vector` — the same query against `kind='text'` ranks the
|
||||
correct memory *lower*; assert the symptom search strictly improves rank.
|
||||
Without this the projection could be doing nothing.
|
||||
5. `a5_deterministic` — generate twice; assert byte-identical projections.
|
||||
6. `a6_rebuild_stable` — drop and rebuild; assert projections and their
|
||||
embeddings match the originals.
|
||||
7. `a7_signature_linked` — a memory whose evidence contains a parseable error has
|
||||
a `failure_signature` row pointing at it, with the tool set.
|
||||
8. `a8_l0_and_r_skipped` — assert no L0 or R node has a symptom vector.
|
||||
9. `a9_model_id_recorded` — the log record names the model and prompt sha.
|
||||
|
||||
**Command:** `cargo test -p mem-core symptom && cargo test -p mem-cli rebuild`
|
||||
|
||||
**False pass:**
|
||||
- Test queries that reuse the memory's own wording. The text vector already finds
|
||||
those, assertion 3 passes, and the projection is never exercised. The error
|
||||
strings must share no meaningful vocabulary — that constraint is the test.
|
||||
- Asserting 3 without 4. If the text vector already ranked it first, assertion 3
|
||||
is satisfied by a projection that is empty or useless.
|
||||
- Skipping determinism because output "looks stable". A default temperature makes
|
||||
it stable for ten runs and different on the eleventh, and the symptom is a
|
||||
rebuild diff nobody can explain.
|
||||
|
||||
## Traps
|
||||
|
||||
- Generating projections for rejected chunks. The gate rejected them; embedding
|
||||
their symptoms puts evidence-free content in the index through a side door.
|
||||
- Letting the projection restate the memory. If the model paraphrases the answer
|
||||
instead of naming the symptoms, the second vector duplicates the first and
|
||||
assertion 4 fails — which is the correct outcome, but the cause is the prompt,
|
||||
not the plumbing.
|
||||
- Treating an empty projection as an error and retrying. It is the right answer
|
||||
for most non-incident memories, and a retry loop turns it into invented
|
||||
symptoms.
|
||||
|
||||
---
|
||||
|
||||
Background: [DESIGN.md](../DESIGN.md) — tool context, write path
|
||||
@@ -4,7 +4,7 @@
|
||||
|---|---|
|
||||
| Phase | M4 — Skills |
|
||||
| Size | M — 1–3 days |
|
||||
| Status | ⬜ Not started |
|
||||
| Status | 🟡 In progress — `render_skill()` and `mem materialize` implemented |
|
||||
| Flags | — |
|
||||
| Spec | inlined below |
|
||||
| Blocks | M3.1 |
|
||||
@@ -14,6 +14,40 @@
|
||||
Turn a memory note into a draft skill — the step that makes the memory *do*
|
||||
something rather than only be read.
|
||||
|
||||
## Existing code (already implemented)
|
||||
|
||||
**`crates/mem-core/src/lesson.rs`** already contains:
|
||||
|
||||
| Function | What it does | Tests |
|
||||
|---|---|---|
|
||||
| `render_skill(tool, lessons)` | Generates `SKILL.md` with YAML frontmatter (`name`, `description`), per-lesson sections with `seen`/`last_seen`/`confidence`/`resolution`, and recurring-failure warnings | `skill_description_lists_symptoms_not_summary` |
|
||||
| `render_injection(hit, max_chars)` | Generates capped injection text for prompts | `injection_is_capped` |
|
||||
|
||||
**`crates/mem-cli/src/lessons_cmd.rs`** already contains:
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `mem materialize` | Writes `skills/<tool>-failures/SKILL.md` per tool + `MEMORY.md` digest. Creates dirs, prints symlink instructions for Claude Code / pi. |
|
||||
|
||||
## What remains to complete this task
|
||||
|
||||
The existing code generates skills from **command-level lessons** (`Lesson` struct). This task requires:
|
||||
|
||||
1. **Draft from L1/L2 memory notes** — `mem skill draft --from <project>/<query-id>` reads a GRU-Mem memory node, not a lesson
|
||||
2. **LLM-assisted conversion** — prompt the model to convert descriptive memory into procedural instruction using the rubric
|
||||
3. **`_drafts/` enforcement** — existing `mem materialize` writes directly to `skills/`; this task must write to `_drafts/` only
|
||||
4. **`generated_from: <sha>` provenance** — link back to the memory node
|
||||
5. **Integration tests** — `tests/it_skill_draft.rs` (7 assertions)
|
||||
|
||||
## Files
|
||||
|
||||
| Action | Path |
|
||||
|---|---|
|
||||
| **Exists** | `crates/mem-core/src/lesson.rs` — `render_skill()` |
|
||||
| **Exists** | `crates/mem-cli/src/lessons_cmd.rs` — `mem materialize` |
|
||||
| Modify | `crates/mem-cli/src/main.rs` — add `Commands::Skill { Draft }` subcommand |
|
||||
| Create | `tests/it_skill_draft.rs` — integration tests (7 assertions) |
|
||||
|
||||
## Facts (inlined — no spec read needed)
|
||||
|
||||
```
|
||||
@@ -92,7 +126,7 @@ frontmatter flag, because a directory cannot be accidentally globbed into
|
||||
7. `a7_idempotent` — same input twice produces identical bytes apart from
|
||||
`generated_at`.
|
||||
|
||||
**Command:** `cargo test -p mem-cli skill_draft`
|
||||
**Command:** `cargo test --test it_skill_draft`
|
||||
|
||||
**False pass:**
|
||||
- Asserting the file was written without asserting *where*. The entire safety
|
||||
|
||||
@@ -47,10 +47,18 @@ threshold.
|
||||
Threshold is a tradeoff and should be logged, not hidden: too low excludes
|
||||
genuine discussion *about* a skill, too high lets the cycle run.
|
||||
|
||||
**A second producer arrives in M3.6.** Reference corpora hit the identical cycle
|
||||
with upstream docs in place of emitted skills, and [M3.6.4](M3.6.4-reference-cycle-guard.md)
|
||||
reuses this matcher rather than building a parallel one — generalising the
|
||||
manifest to `vault/.artifacts.jsonl` with a `kind` field and adding a per-kind
|
||||
threshold. Build the manifest record with that in mind: a `kind: "skill"` field
|
||||
from the first line costs nothing now and avoids a migration of an append-only
|
||||
file later.
|
||||
|
||||
## Steps
|
||||
|
||||
1. `vault/skills/.manifest.jsonl` — one line per emitted artifact:
|
||||
`{name, sha256, shingles, emitted_at}`.
|
||||
`{kind: "skill", name, sha256, shingles, emitted_at}`.
|
||||
2. `mem skill draft` appends to it.
|
||||
3. `mem-ingest` loads the manifest and computes shingle overlap per record.
|
||||
4. Overlap > threshold (default 0.8): tag `derived: true`, exclude from chunking.
|
||||
|
||||
Reference in New Issue
Block a user