mirror of
https://github.com/samvallad33/vestige.git
synced 2026-06-20 21:18:08 +02:00
feat(merge-supersede): Phase 3 — diff-previewed, reversible merge/supersede controls (v2.1.25) (#75)
Adds opt-in, preview-first combine/dedupe/supersede on a never-delete (bitemporal) store. The default is review, never silent mutation. Every applied operation is recorded as a reversible, auditable event with provenance — a git reflog for your agent's memory. Core (vestige-core): - advanced::merge_supersede — pure Fellegi-Sunter two-threshold scoring (embedding + tag + token Jaccard), match/possible/non_match classification, plan/diff and operation-log types, merge-composition helpers. Unit-tested. - storage: merge_candidates, plan_merge, plan_supersede, apply_plan, merge_undo, protect/pin, and per-project merge_policy (persisted in fsrs_config, env overridable). Supersede invalidates bitemporally (valid_until + superseded_by, Graphiti-style "invalidate, don't delete") and keeps the old node queryable. - Migration V14: merge_plans + merge_operations tables, knowledge_nodes.protected and .superseded_by columns + indexes. Idempotent on replay (duplicate-column guarded ADD COLUMNs). MCP (vestige-mcp): - Seven new tools registered + dispatched: merge_candidates, plan_merge, plan_supersede, apply_plan, merge_undo, protect, merge_policy. - apply_plan requires confirm=true for possible/non_match plans; match plans auto-apply only when policy.auto_apply is set (default off). Tests: candidate-threshold classification, plan-preview makes no mutation, apply+undo reversibility, supersede bitemporal invalidation preserves old-node queryability, protect blocks merge-away, low-confidence requires confirm, policy roundtrip, migration V14 + idempotent replay. All 796 scoped tests pass; clippy -D warnings clean on touched crates. Docs: docs/MERGE_SUPERSEDE.md + CHANGELOG entry. Version bump 2.1.23 -> 2.1.25. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b01269db22
commit
c23d7a309c
19 changed files with 2704 additions and 19 deletions
152
docs/MERGE_SUPERSEDE.md
Normal file
152
docs/MERGE_SUPERSEDE.md
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
# Merge / Supersede Controls (Phase 3)
|
||||
|
||||
> Diff-previewed, confidence-gated, reversible, self-explaining
|
||||
> combine/dedupe/supersede on a never-delete (bitemporal) store.
|
||||
|
||||
Memory systems accumulate duplicates, near-duplicates, and outdated facts. The
|
||||
naive fixes are all bad: dumb hashing under-merges (misses paraphrases),
|
||||
aggressive LLM merging over-merges and destroys the audit trail, and
|
||||
auto-deleting on contradiction silently loses information. Vestige's Phase 3
|
||||
takes the opposite stance:
|
||||
|
||||
- **Opt-in, never silent.** The default is preview/review. Nothing mutates your
|
||||
memory unless you explicitly apply a plan.
|
||||
- **Diff-previewed.** `plan_merge` / `plan_supersede` show exactly what *would*
|
||||
change before anything does.
|
||||
- **Confidence-gated.** A Fellegi-Sunter two-threshold score classifies each
|
||||
candidate as `match` / `possible` / `non_match`.
|
||||
- **Reversible.** Every applied operation is recorded with an undo payload — a
|
||||
*git reflog for your agent's memory*.
|
||||
- **Self-explaining.** Each candidate carries the signals that explain *why* two
|
||||
memories were judged duplicates.
|
||||
- **Audit-preserving.** Superseding does not delete: it stamps `valid_until` and
|
||||
keeps the old memory queryable (Graphiti-style "invalidate, don't delete").
|
||||
|
||||
## The bitemporal model: invalidate, don't delete
|
||||
|
||||
Superseding memory A with memory B does **not** erase A. Instead:
|
||||
|
||||
- `A.valid_until` is stamped with the supersede time.
|
||||
- `A.superseded_by` is set to `B.id` (a lineage pointer).
|
||||
- A remains fully queryable for audit. Searches and timelines can still surface
|
||||
it; it is simply marked as no longer the current truth.
|
||||
|
||||
This reuses the existing `valid_from` / `valid_until` columns on
|
||||
`knowledge_nodes` (migration V2) plus a new `superseded_by` column (migration
|
||||
V14). Merges work the same way: the survivor absorbs the others' content, and
|
||||
each absorbed node is bitemporally invalidated rather than deleted.
|
||||
|
||||
## Fellegi-Sunter two-threshold scoring
|
||||
|
||||
Candidate scoring combines three signals into a weighted score in `[0, 1]`:
|
||||
|
||||
| Signal | Weight | Source |
|
||||
| ----------------------- | -----: | ------------------------------------------ |
|
||||
| Embedding cosine sim | 0.70 | stored embeddings (`node_embeddings`) |
|
||||
| Tag overlap (Jaccard) | 0.15 | `knowledge_nodes.tags` |
|
||||
| Content token overlap | 0.15 | Jaccard over content tokens (len > 2) |
|
||||
|
||||
The combined score is then classified against **two** thresholds:
|
||||
|
||||
```
|
||||
score >= match_threshold => "match" (auto-merge eligible)
|
||||
possible_threshold <= score => "possible" (surfaced for review)
|
||||
score < possible_threshold => "non_match" (never offered)
|
||||
```
|
||||
|
||||
Defaults: `match_threshold = 0.86`, `possible_threshold = 0.72`. The two-band
|
||||
design means borderline cases are surfaced for review instead of being
|
||||
force-decided in either direction.
|
||||
|
||||
A cluster's confidence is the **weakest** pairwise score within it (the loosest
|
||||
link), so a cluster is only as confident as its least-similar member.
|
||||
|
||||
## The reversible operation log (the "memory reflog")
|
||||
|
||||
Every applied merge/supersede writes one row to `merge_operations`:
|
||||
|
||||
- `op_type` — `merge` | `supersede` | `undo`
|
||||
- `status` — `applied` | `reverted`
|
||||
- `survivor_id`, `affected_ids` — what was touched
|
||||
- `confidence`, `signals` — the score and *why* the memories combined
|
||||
- `reason` — a human-readable explanation
|
||||
- `undo_payload` — a JSON snapshot capturing everything needed to reverse it
|
||||
|
||||
`merge_undo` consumes the undo payload to restore the survivor's prior
|
||||
content/tags and clear the bitemporal invalidation on every affected node, then
|
||||
records a compensating `undo` operation. Calling `merge_undo` with no
|
||||
`operation_id` returns the operation log so you can pick one.
|
||||
|
||||
## Memory protection (pinning)
|
||||
|
||||
`protect` sets the `protected` flag on a memory. A protected memory:
|
||||
|
||||
- is never offered for auto-merge (it is flagged in `merge_candidates`),
|
||||
- cannot be merged *away* (it may only be the survivor of a merge),
|
||||
- cannot be superseded,
|
||||
- is excluded from garbage collection.
|
||||
|
||||
Pass `protected: false` to unpin.
|
||||
|
||||
## Tool surface
|
||||
|
||||
| Tool | Mutates? | Purpose |
|
||||
| ------------------ | :------: | ------------------------------------------------------------------------- |
|
||||
| `merge_candidates` | No | Surface likely duplicate clusters with confidence + signals. |
|
||||
| `plan_merge` | No | Preview a merge of 2+ memories (a diff). Returns a `plan_id`. |
|
||||
| `plan_supersede` | No | Preview superseding A with B (bitemporal). Returns a `plan_id`. |
|
||||
| `apply_plan` | **Yes** | Execute a plan by id; recorded as a reversible operation. |
|
||||
| `merge_undo` | **Yes** | Reverse an operation, or list the operation log when given no id. |
|
||||
| `protect` | **Yes** | Pin / unpin a memory so it can never be auto-merged/superseded/forgotten. |
|
||||
| `merge_policy` | **Yes** | Get/set the two thresholds + `auto_apply`. |
|
||||
|
||||
### Typical flow
|
||||
|
||||
```text
|
||||
1. merge_candidates -> review clusters + confidence + signals
|
||||
2. plan_merge { member_ids: [...] } -> inspect the diff, get plan_id
|
||||
3. apply_plan { plan_id, confirm } -> apply; get operation_id (reversible)
|
||||
4. merge_undo { operation_id } -> reverse if it was wrong
|
||||
```
|
||||
|
||||
`apply_plan` requires `confirm: true` for `possible` / `non_match` plans. A
|
||||
`match` plan applies without `confirm` only when the policy has
|
||||
`auto_apply: true` (default `false`).
|
||||
|
||||
## Configuration
|
||||
|
||||
The merge policy persists per project (stored in `fsrs_config`). It can also be
|
||||
overridden via environment variables:
|
||||
|
||||
| Variable | Meaning |
|
||||
| ----------------------------------- | ------------------------------------ |
|
||||
| `VESTIGE_MERGE_MATCH_THRESHOLD` | Score ≥ this ⇒ `match`. |
|
||||
| `VESTIGE_MERGE_POSSIBLE_THRESHOLD` | Score ≥ this ⇒ at least `possible`. |
|
||||
| `VESTIGE_MERGE_AUTO_APPLY` | `1`/`true` to allow auto-apply. |
|
||||
|
||||
A persisted policy (set via `merge_policy`) takes precedence over the
|
||||
environment, which takes precedence over the built-in defaults. When
|
||||
`vestige.toml` configuration lands, the policy will read from there as well.
|
||||
|
||||
## Schema (migration V14)
|
||||
|
||||
- `knowledge_nodes.protected INTEGER NOT NULL DEFAULT 0`
|
||||
- `knowledge_nodes.superseded_by TEXT`
|
||||
- `merge_plans(id, kind, status, created_at, applied_at, survivor_id,
|
||||
member_ids, confidence, classification, payload)`
|
||||
- `merge_operations(id, plan_id, op_type, status, created_at, reverted_at,
|
||||
reverts_op_id, survivor_id, affected_ids, confidence, signals, reason,
|
||||
undo_payload)`
|
||||
|
||||
The two `ALTER TABLE ... ADD COLUMN` statements are applied with duplicate-column
|
||||
guards so the migration is idempotent on replay; the rest of V14 uses
|
||||
`CREATE ... IF NOT EXISTS`.
|
||||
|
||||
## Anti-patterns this design avoids
|
||||
|
||||
- **Silently double-storing contradictions.** Merge composition attributes and
|
||||
de-duplicates content instead of blindly concatenating or dropping it.
|
||||
- **Auto-deleting on contradiction.** Supersede invalidates bitemporally; the
|
||||
old memory is retained and queryable.
|
||||
- **Trading away the audit trail for auto-merge convenience.** Every operation is
|
||||
logged and reversible, with provenance for why memories combined.
|
||||
Loading…
Add table
Add a link
Reference in a new issue