Files
poimen-memory/tasks/M2.3-schema-and-migrations.md
T

168 lines
7.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# M2.3 — Schema + sqlx migrations
| Field | Value |
|---|---|
| Phase | M2 — Projections |
| Size | M — 13 days |
| Status | ⬜ Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | M2.2 |
## Goal
The tables the provenance graph lives in, with the constraints that make a
malformed graph impossible rather than merely unlikely.
## Facts (inlined — no spec read needed)
```sql
CREATE TABLE memory_node (
id BIGSERIAL PRIMARY KEY,
level TEXT NOT NULL CHECK (level IN ('L0','L1','L2','R')),
project TEXT NOT NULL,
query_id TEXT, -- NULL at L2 and R
run_id TEXT NOT NULL,
t INT NOT NULL,
source TEXT, -- set at L0; source URI at R
text TEXT NOT NULL,
sha256 TEXT NOT NULL UNIQUE, -- content identity, from M0.2
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE memory_edge (
child_sha TEXT NOT NULL REFERENCES memory_node(sha256) ON DELETE CASCADE,
parent_sha TEXT NOT NULL REFERENCES memory_node(sha256) ON DELETE CASCADE,
PRIMARY KEY (child_sha, parent_sha)
);
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).
`ON DELETE CASCADE` on both edge columns: dropping a node should not leave
dangling edges. Rebuild drops everything anyway, but a partial cleanup should not
be able to corrupt the graph.
`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
uses (`<=>`), or the index is silently ignored and every query is a seq scan.
## Steps
1. `migrations/0001_init.sql` with the above, plus the `query_id`/level CHECK.
2. `CREATE EXTENSION IF NOT EXISTS vector;` first — the declarative extension
(M2.2) should have run, and this makes local dev work too.
3. Wire `sqlx::migrate!()` and run at startup.
4. `sqlx prepare` for offline compile-time query checking in CI.
5. Add a `schema_version` sanity query the repo layer asserts on connect.
6. Document that changing the embedding model is a migration, because the column
width is part of the schema.
## Acceptance
- Migrations apply to a clean database and are idempotent.
- Inserting a duplicate `sha256` conflicts rather than duplicating.
- 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
**Harness:** a disposable database — `sqlx::test` or testcontainers with the same
image tag as production, `ghcr.io/cloudnative-pg/postgresql:16.2`.
**Integration test**`tests/it_schema.rs`:
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'` 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, 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`
**False pass:**
- Testing the schema against SQLite or plain Postgres without pgvector. It will
accept `vector(768)` as an unknown type in some configurations and every
vector assertion becomes meaningless. Use the production image.
- Omitting assertion 7. An index created with the wrong opclass exists, reports
healthy, and is never used — queries just get slower as the table grows, which
reads as a scaling problem rather than a wrong index.
## Traps
- `vector_l2_ops` with a `<=>` query, or the reverse. The index is silently
ignored. This is the single most common pgvector mistake.
- Making `query_id` NOT NULL because L1 always has one. L2 then cannot be stored,
and the workaround is a sentinel string that pollutes every group-by.
---
Background: [DESIGN.md](../DESIGN.md) — pgvector schema