diff --git a/cmd/memory/brief.go b/cmd/memory/brief.go index ce0b4ed5..d1945382 100644 --- a/cmd/memory/brief.go +++ b/cmd/memory/brief.go @@ -18,6 +18,7 @@ type briefResult struct { Category string `json:"category,omitempty"` Score *float64 `json:"score,omitempty"` Confidence string `json:"confidence,omitempty"` + Superseded bool `json:"superseded,omitempty"` } type briefResponse struct { diff --git a/cmd/memory/import_supersedes_test.go b/cmd/memory/import_supersedes_test.go new file mode 100644 index 00000000..ff2c0ca3 --- /dev/null +++ b/cmd/memory/import_supersedes_test.go @@ -0,0 +1,60 @@ +package memory + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/mnemon-dev/mnemon/internal/memory/store" +) + +func TestImportDeduplicationCannotSupersedeItself(t *testing.T) { + t.Setenv("MNEMON_EMBED_ENDPOINT", "http://127.0.0.1:1") + t.Setenv("MNEMON_EMBED_PROTOCOL", "ollama") + oldDir, oldStore, oldReadOnly := dataDir, storeName, readOnly + oldNoDiff, oldDryRun := importNoDiff, importDryRun + t.Cleanup(func() { + dataDir, storeName, readOnly = oldDir, oldStore, oldReadOnly + importNoDiff, importDryRun = oldNoDiff, oldDryRun + }) + dataDir, storeName, readOnly = t.TempDir(), "dedup-supersedes", false + importNoDiff, importDryRun = false, false + path := filepath.Join(t.TempDir(), "draft.json") + if err := os.WriteFile(path, []byte(`{ + "schema_version":"1", + "insights":[{"content":"release cache ttl is seven days"},{"content":"release cache ttl is seven days"}], + "edges":[{"source_index":1,"target_index":0,"edge_type":"supersedes","weight":1}] + }`), 0o600); err != nil { + t.Fatal(err) + } + var runErr error + output := captureStdout(t, func() { runErr = importCmd.RunE(importCmd, []string{path}) }) + if runErr != nil { + t.Fatal(runErr) + } + var summary struct { + Imported int `json:"imported"` + Skipped int `json:"skipped"` + EdgesInserted int `json:"edges_inserted"` + } + if err := json.Unmarshal([]byte(output), &summary); err != nil { + t.Fatal(err) + } + if summary.Imported != 1 || summary.Skipped != 1 || summary.EdgesInserted != 0 { + t.Fatalf("deduplicated import must not write a self supersedes edge: %s", output) + } + db, err := store.Open(store.StoreDir(dataDir, storeName)) + if err != nil { + t.Fatal(err) + } + defer db.Close() + active, err := db.GetAllActiveInsights() + if err != nil || len(active) != 1 { + t.Fatalf("expected one retained insight: %v, %v", active, err) + } + superseded, err := db.GetSupersededIDs([]string{active[0].ID}) + if err != nil || len(superseded) != 0 { + t.Fatalf("the only current fact was superseded: %v, %v", superseded, err) + } +} diff --git a/cmd/memory/link.go b/cmd/memory/link.go index a0f04cb1..c899ac2f 100644 --- a/cmd/memory/link.go +++ b/cmd/memory/link.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "strings" "time" "github.com/mnemon-dev/mnemon/internal/memory/model" @@ -28,7 +29,8 @@ var linkCmd = &cobra.Command{ // Validate edge type edgeType := model.EdgeType(linkType) if !model.ValidEdgeTypes[edgeType] { - return fmt.Errorf("invalid edge type %q; valid: temporal, semantic, causal, entity", linkType) + return fmt.Errorf("invalid edge type %q; valid: %s", + linkType, strings.Join(model.EdgeTypeNames(), ", ")) } // Validate weight @@ -63,7 +65,7 @@ var linkCmd = &cobra.Command{ now := time.Now().UTC() - // Create bidirectional edges (INSERT OR REPLACE) + // Mutual relations are recorded in both directions. err = db.InsertEdge(&model.Edge{ SourceID: sourceID, TargetID: targetID, @@ -76,16 +78,23 @@ var linkCmd = &cobra.Command{ return fmt.Errorf("create edge %s→%s: %w", sourceID, targetID, err) } - err = db.InsertEdge(&model.Edge{ - SourceID: targetID, - TargetID: sourceID, - EdgeType: edgeType, - Weight: linkWeight, - Metadata: metadata, - CreatedAt: now, - }) - if err != nil { - return fmt.Errorf("create edge %s→%s: %w", targetID, sourceID, err) + // A directed relation is recorded once. Writing the reverse of a + // supersedes edge would mark the correction as superseded too, so + // both insights would be demoted and the stale one would keep its + // lead over the correction -- the exact ordering this type exists + // to fix. + if !edgeType.IsDirected() { + err = db.InsertEdge(&model.Edge{ + SourceID: targetID, + TargetID: sourceID, + EdgeType: edgeType, + Weight: linkWeight, + Metadata: metadata, + CreatedAt: now, + }) + if err != nil { + return fmt.Errorf("create edge %s→%s: %w", targetID, sourceID, err) + } } db.LogOp("link", sourceID, fmt.Sprintf("%s→%s type=%s weight=%.2f", truncID(sourceID), truncID(targetID), linkType, linkWeight)) @@ -105,7 +114,8 @@ var linkCmd = &cobra.Command{ } func init() { - linkCmd.Flags().StringVar(&linkType, "type", "semantic", "edge type (temporal|semantic|causal|entity)") + linkCmd.Flags().StringVar(&linkType, "type", "semantic", + fmt.Sprintf("edge type (%s)", strings.Join(model.EdgeTypeNames(), "|"))) linkCmd.Flags().Float64Var(&linkWeight, "weight", 0.5, "edge weight (0.0-1.0)") linkCmd.Flags().StringVar(&linkMeta, "meta", "", `optional metadata JSON (e.g. '{"reason":"similar topic"}')`) rootCmd.AddCommand(linkCmd) diff --git a/cmd/memory/link_test.go b/cmd/memory/link_test.go new file mode 100644 index 00000000..a4b10185 --- /dev/null +++ b/cmd/memory/link_test.go @@ -0,0 +1,80 @@ +package memory + +import ( + "slices" + "sort" + "strings" + "testing" + + "github.com/mnemon-dev/mnemon/internal/memory/store" +) + +// A supersedes edge asserts something about one of the two insights. Recorded +// both ways it marks the correction as superseded too, so recall demotes both +// and the stale insight keeps its lead over the correction -- the ordering the +// type exists to fix. Mutual types must still be recorded in both directions. +func TestLinkRecordsDirectedEdgeOnceAndMutualEdgesBothWays(t *testing.T) { + oldDataDir, oldStoreName, oldReadOnly := dataDir, storeName, readOnly + oldType, oldWeight, oldMeta := linkType, linkWeight, linkMeta + t.Cleanup(func() { + dataDir, storeName, readOnly = oldDataDir, oldStoreName, oldReadOnly + linkType, linkWeight, linkMeta = oldType, oldWeight, oldMeta + }) + dataDir, storeName, readOnly = t.TempDir(), "", false + linkWeight, linkMeta = 0.5, "" + + db, err := store.Open(store.StoreDir(dataDir, store.DefaultStoreName)) + if err != nil { + t.Fatalf("open store: %v", err) + } + insertTestInsight(t, db, "fresh", "the correction", "test", "2026-01-02T00:00:00Z") + insertTestInsight(t, db, "stale", "the corrected claim", "test", "2026-01-01T00:00:00Z") + if err := db.Close(); err != nil { + t.Fatalf("close seed db: %v", err) + } + + cases := []struct { + edgeType string + want []string // source id of every edge of this type + }{ + {edgeType: "supersedes", want: []string{"fresh"}}, + {edgeType: "semantic", want: []string{"fresh", "stale"}}, + } + for _, tc := range cases { + t.Run(tc.edgeType, func(t *testing.T) { + linkType = tc.edgeType + captureStdout(t, func() { + if err := linkCmd.RunE(linkCmd, []string{"fresh", "stale"}); err != nil { + t.Fatalf("link RunE: %v", err) + } + }) + + db, err := store.Open(store.StoreDir(dataDir, store.DefaultStoreName)) + if err != nil { + t.Fatalf("reopen store: %v", err) + } + defer db.Close() + edges, err := db.GetAllEdges() + if err != nil { + t.Fatalf("read edges: %v", err) + } + + var got []string + for _, e := range edges { + if string(e.EdgeType) == tc.edgeType { + got = append(got, e.SourceID) + } + } + sort.Strings(got) + if !slices.Equal(got, tc.want) { + t.Errorf("%s edges by source = %v, want %v", tc.edgeType, got, tc.want) + } + }) + } + t.Run("reject self supersession", func(t *testing.T) { + linkType = "supersedes" + if err := linkCmd.RunE(linkCmd, []string{"fresh", "fresh"}); err == nil || !strings.Contains(err.Error(), "distinct insights") { + t.Fatalf("self supersedes link must fail: %v", err) + } + }) +} diff --git a/cmd/memory/recall.go b/cmd/memory/recall.go index a003c851..9acc07cf 100644 --- a/cmd/memory/recall.go +++ b/cmd/memory/recall.go @@ -38,6 +38,10 @@ type compactResult struct { MatchedVia string `json:"matched_via,omitempty"` Confidence string `json:"confidence"` Score float64 `json:"score"` + // Superseded warns that another insight claims to replace this one. + // It must survive the compact projection: an agent reading only this + // shape would otherwise be handed corrected content with no signal. + Superseded bool `json:"superseded,omitempty"` } // compactResponse wraps compact results with an optional hint. @@ -89,6 +93,7 @@ func toCompact(resp search.RecallResponse) compactResponse { MatchedVia: r.Via, Confidence: confidenceLabel(rounded), Score: rounded, + Superseded: r.Superseded, } results = append(results, cr) } @@ -212,6 +217,7 @@ meta.intent and meta.intent_source (auto or override). --basic bypasses intent.` Category: string(result.Insight.Category), Score: scorePointer(score), Confidence: confidenceLabel(score), + Superseded: result.Superseded, }) } return encodeBrief(os.Stdout, newBriefResponse(brief, resp.Meta.Hint)) diff --git a/cmd/memory/recall_supersedes_test.go b/cmd/memory/recall_supersedes_test.go new file mode 100644 index 00000000..c7b7f3a6 --- /dev/null +++ b/cmd/memory/recall_supersedes_test.go @@ -0,0 +1,80 @@ +package memory + +import ( + "encoding/json" + "testing" + "time" + + "github.com/mnemon-dev/mnemon/internal/memory/model" + "github.com/mnemon-dev/mnemon/internal/memory/store" +) + +func TestRecallPreservesSupersededInEverySmartProjection(t *testing.T) { + oldDir, oldStore, oldReadOnly := dataDir, storeName, readOnly + oldBasic, oldBrief, oldVerbose, oldLimit := recBasic, recBrief, recVerbose, recLimit + oldCategory, oldSource, oldIntent, oldExcerpt := recCategory, recSource, recIntent, recExcerpt + t.Cleanup(func() { + dataDir, storeName, readOnly = oldDir, oldStore, oldReadOnly + recBasic, recBrief, recVerbose, recLimit = oldBasic, oldBrief, oldVerbose, oldLimit + recCategory, recSource, recIntent, recExcerpt = oldCategory, oldSource, oldIntent, oldExcerpt + }) + t.Setenv("MNEMON_EMBED_ENDPOINT", "http://127.0.0.1:1") + t.Setenv("MNEMON_EMBED_PROTOCOL", "ollama") + dataDir, storeName, readOnly = t.TempDir(), "supersedes-projection", true + recBasic, recLimit = false, 5 + recCategory, recSource, recIntent, recExcerpt = "", "", "GENERAL", 240 + db, err := store.Open(store.StoreDir(dataDir, storeName)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + insertTestInsight(t, db, "stale", "release cache ttl defaults to thirty days", "test", "2026-01-01T00:00:00Z") + insertTestInsight(t, db, "fresh", "release cache ttl was corrected: use seven days", "test", "2026-01-02T00:00:00Z") + if err := db.InsertEdge(&model.Edge{ + SourceID: "fresh", TargetID: "stale", EdgeType: model.EdgeSupersedes, + Weight: 1, CreatedAt: time.Now().UTC(), + }); err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + + for _, mode := range []string{"compact", "verbose", "brief"} { + t.Run(mode, func(t *testing.T) { + recBrief, recVerbose = mode == "brief", mode == "verbose" + var runErr error + out := captureStdout(t, func() { runErr = recallCmd.RunE(recallCmd, []string{"release cache ttl thirty days"}) }) + if runErr != nil { + t.Fatal(runErr) + } + var response struct { + Results []struct { + ID string `json:"id"` + Insight model.Insight + Superseded *bool `json:"superseded"` + } + } + if err := json.Unmarshal([]byte(out), &response); err != nil { + t.Fatal(err) + } + seen := map[string]bool{} + for _, result := range response.Results { + id := result.ID + if mode == "verbose" { + id = result.Insight.ID + } + seen[id] = true + if id == "stale" && (result.Superseded == nil || !*result.Superseded) { + t.Errorf("superseded marker missing from stale result: %s", out) + } + if id == "fresh" && result.Superseded != nil { + t.Errorf("current insight must omit superseded: %s", out) + } + } + if !seen["stale"] || !seen["fresh"] { + t.Fatalf("both current and superseded memories must remain retrievable: %s", out) + } + }) + } +} diff --git a/cmd/memory/related.go b/cmd/memory/related.go index 44152e06..16c165dd 100644 --- a/cmd/memory/related.go +++ b/cmd/memory/related.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "strings" "github.com/mnemon-dev/mnemon/internal/memory/graph" "github.com/mnemon-dev/mnemon/internal/memory/model" @@ -40,7 +41,8 @@ var relatedCmd = &cobra.Command{ if relEdgeType != "" { et := model.EdgeType(relEdgeType) if !model.ValidEdgeTypes[et] { - return fmt.Errorf("invalid edge type %q; valid: temporal, semantic, causal, entity", relEdgeType) + return fmt.Errorf("invalid edge type %q; valid: %s", + relEdgeType, strings.Join(model.EdgeTypeNames(), ", ")) } edgeFilter = et } @@ -83,7 +85,8 @@ func bfsTraverse(db *store.DB, startID string, edgeFilter model.EdgeType, maxDep } func init() { - relatedCmd.Flags().StringVar(&relEdgeType, "edge", "", "filter by edge type (temporal|semantic|causal|entity)") + relatedCmd.Flags().StringVar(&relEdgeType, "edge", "", + fmt.Sprintf("filter by edge type (%s)", strings.Join(model.EdgeTypeNames(), "|"))) relatedCmd.Flags().IntVar(&relDepth, "depth", 2, "max traversal depth") rootCmd.AddCommand(relatedCmd) } diff --git a/docs/IMPORT.md b/docs/IMPORT.md index f45973b3..5cc15b8e 100644 --- a/docs/IMPORT.md +++ b/docs/IMPORT.md @@ -120,6 +120,7 @@ Chat export / Markdown -> LLM extraction prompt -> memory_draft.json -> mnemon i | `causal` | Causal influence; A caused or affected B | | `semantic` | Semantic similarity; A and B discuss the same topic | | `entity` | Entity co-occurrence; A and B mention the same named subject | +| `supersedes` | Authority claim; A replaces B and B is demoted at recall | --- diff --git a/docs/USAGE.md b/docs/USAGE.md index 4e1674ae..765ac460 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -252,6 +252,7 @@ selection entirely. mnemon link --type semantic --weight 0.85 mnemon link --type causal --weight 0.8 \ --meta '{"sub_type":"causes","reason":"..."}' +mnemon link --type supersedes --weight 1.0 # Related — BFS traversal from an insight mnemon related --edge causal --depth 2 @@ -345,7 +346,7 @@ mnemon viz --format html -o graph.html open graph.html ``` -Nodes are colored by category (decision, fact, insight, preference, context); edges are colored by type (temporal, semantic, causal, entity). +Nodes are colored by category (decision, fact, insight, preference, context); edges are colored by type (temporal, semantic, causal, entity, supersedes). --- diff --git a/docs/zh/IMPORT.md b/docs/zh/IMPORT.md index 63cdd84c..bac197ed 100644 --- a/docs/zh/IMPORT.md +++ b/docs/zh/IMPORT.md @@ -119,6 +119,7 @@ | `causal` | 因果关系,A 导致或影响 B | | `semantic` | 语义相似关系,A 与 B 讨论同一主题 | | `entity` | 实体共现关系,A 与 B 涉及同一命名主体 | +| `supersedes` | 权威声明:A 取代 B,召回时 B 被降权 | --- diff --git a/docs/zh/USAGE.md b/docs/zh/USAGE.md index 787e849e..98682e26 100644 --- a/docs/zh/USAGE.md +++ b/docs/zh/USAGE.md @@ -224,6 +224,7 @@ WHEN 为时间、ENTITY 为是什么/是谁、GENERAL 为中性遍历。意图 mnemon link --type semantic --weight 0.85 mnemon link --type causal --weight 0.8 \ --meta '{"sub_type":"causes","reason":"..."}' +mnemon link --type supersedes --weight 1.0 # Related — 从某个洞察出发的 BFS 遍历 mnemon related --edge causal --depth 2 @@ -315,7 +316,7 @@ mnemon viz --format html -o graph.html open graph.html ``` -节点按分类着色(decision、fact、insight、preference、context),边按类型着色(temporal、semantic、causal、entity)。 +节点按分类着色(decision、fact、insight、preference、context),边按类型着色(temporal、semantic、causal、entity、supersedes)。 --- diff --git a/internal/memory/graph/causal.go b/internal/memory/graph/causal.go index 4ba6fbe7..b38fd21b 100644 --- a/internal/memory/graph/causal.go +++ b/internal/memory/graph/causal.go @@ -171,8 +171,8 @@ type NeighborNode struct { } // GetNeighborhood performs a BFS from nodeID up to maxHops, following all edge -// types (temporal, semantic, causal, entity). Returns up to maxNodes neighbor -// nodes, excluding the start node and soft-deleted nodes. +// types. Returns up to maxNodes neighbor nodes, excluding the start node and +// soft-deleted nodes. func GetNeighborhood(db *store.DB, nodeID string, maxHops int, maxNodes int) []NeighborNode { nodes := BFS(db, nodeID, BFSOptions{MaxDepth: maxHops, MaxNodes: maxNodes}) result := make([]NeighborNode, len(nodes)) diff --git a/internal/memory/importdraft/draft.go b/internal/memory/importdraft/draft.go index de621c2c..f3d17492 100644 --- a/internal/memory/importdraft/draft.go +++ b/internal/memory/importdraft/draft.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "os" + "strings" "time" "github.com/mnemon-dev/mnemon/internal/memory/model" @@ -72,7 +73,7 @@ type DraftEdge struct { TargetIndex int `json:"target_index"` // EdgeType is the kind of relationship. - // One of: temporal, semantic, causal, entity. + // One of: temporal, semantic, causal, entity, supersedes. EdgeType string `json:"edge_type"` // Weight is the edge strength in [0.0, 1.0]. Defaults to 0.5. @@ -166,7 +167,7 @@ func (d *MemoryDraft) Validate() error { return fmt.Errorf("edges[%d]: source_index and target_index must differ", i) } if !model.ValidEdgeTypes[model.EdgeType(edge.EdgeType)] { - return fmt.Errorf("edges[%d]: invalid edge_type %q (valid: temporal, semantic, causal, entity)", i, edge.EdgeType) + return fmt.Errorf("edges[%d]: invalid edge_type %q (valid: %s)", i, edge.EdgeType, strings.Join(model.EdgeTypeNames(), ", ")) } if edge.Weight < 0 || edge.Weight > 1.0 { return fmt.Errorf("edges[%d]: weight %g out of range [0.0, 1.0]", i, edge.Weight) diff --git a/internal/memory/model/edge.go b/internal/memory/model/edge.go index 5446f77a..0f93f349 100644 --- a/internal/memory/model/edge.go +++ b/internal/memory/model/edge.go @@ -2,6 +2,7 @@ package model import ( "encoding/json" + "sort" "time" ) @@ -13,13 +14,37 @@ const ( EdgeSemantic EdgeType = "semantic" EdgeCausal EdgeType = "causal" EdgeEntity EdgeType = "entity" + // EdgeSupersedes records that the source insight replaces the target. + // Unlike the other types it is not a similarity or co-occurrence signal + // but an authority claim: the target is retained for lineage and is + // demoted in recall so a corrected fact stops outranking its correction. + EdgeSupersedes EdgeType = "supersedes" ) var ValidEdgeTypes = map[EdgeType]bool{ - EdgeTemporal: true, - EdgeSemantic: true, - EdgeCausal: true, - EdgeEntity: true, + EdgeTemporal: true, + EdgeSemantic: true, + EdgeCausal: true, + EdgeEntity: true, + EdgeSupersedes: true, +} + +// IsDirected reports whether the relation holds only from source to target. +// Similarity and co-occurrence are mutual, so callers record them both ways. +// Supersession is not mutual: it is a claim that one insight replaces the +// other, and the reverse edge would assert that a correction is itself +// superseded, demoting it alongside what it corrects. +func (t EdgeType) IsDirected() bool { return t == EdgeSupersedes } + +// EdgeTypeNames returns the valid type names in a stable order, so help and +// error text cannot drift from the set actually accepted. +func EdgeTypeNames() []string { + names := make([]string, 0, len(ValidEdgeTypes)) + for t := range ValidEdgeTypes { + names = append(names, string(t)) + } + sort.Strings(names) + return names } // Edge represents a directed relationship between two insights. diff --git a/internal/memory/search/integration_test.go b/internal/memory/search/integration_test.go index 51204aa3..9453db33 100644 --- a/internal/memory/search/integration_test.go +++ b/internal/memory/search/integration_test.go @@ -530,3 +530,85 @@ func TestBeamSearchFromAnchor_MaxVisitedBudget(t *testing.T) { t.Errorf("MaxVisited=5: want at most 4 discovered nodes, got %d", discovered) } } + +// --- supersedes demotion --- + +// Regression: a correction must outrank the text it corrects. +// +// The failure this defends against is subtle and was observed in a real store. +// A correction is usually written as a diff ("X is wrong, use Y"), so it +// contains the wrong wording verbatim. A query phrased with the wrong wording +// therefore matches the STALE row at least as well as the correction, and the +// stale row is older, so it has accumulated more edges and a higher access +// count. With scoring built only from keyword/similarity/entity/graph, the +// corrected text reliably beats its own correction and gets served as current. +func TestIntentAwareRecall_SupersededInsightIsDemoted(t *testing.T) { + db := testDB(t) + now := time.Now().UTC() + + // Both rows discuss the same subject with near-identical vocabulary. + stale := insertInsight(t, db, "stale", "deploy target is the staging cluster", "user", 5, nil, now.Add(-72*time.Hour)) + fresh := insertInsight(t, db, "fresh", "deploy target is the staging cluster no longer; use production", "user", 5, nil, now) + + // Give the stale row the graph advantage age confers in a real store. + for i := range 3 { + neighbor := "n" + string(rune('a'+i)) + insertInsight(t, db, neighbor, "deploy notes", "user", 3, nil, now.Add(-72*time.Hour)) + db.InsertEdge(&model.Edge{SourceID: stale.ID, TargetID: neighbor, EdgeType: model.EdgeSemantic, Weight: 0.9, Metadata: map[string]string{}, CreatedAt: now}) + } + + query := "deploy target staging cluster" + + before, err := IntentAwareRecall(db, query, nil, nil, 5, nil) + if err != nil { + t.Fatalf("recall before: %v", err) + } + staleBefore, freshBefore := scoreOf(before.Results, "stale"), scoreOf(before.Results, "fresh") + if staleBefore <= freshBefore { + t.Skipf("fixture did not reproduce the stale-wins condition (stale=%.4f fresh=%.4f); demotion still asserted below", staleBefore, freshBefore) + } + + // Record the supersession. + if err := db.InsertEdge(&model.Edge{ + SourceID: fresh.ID, TargetID: stale.ID, EdgeType: model.EdgeSupersedes, + Weight: 1.0, Metadata: map[string]string{}, CreatedAt: now, + }); err != nil { + t.Fatalf("insert supersedes edge: %v", err) + } + + after, err := IntentAwareRecall(db, query, nil, nil, 5, nil) + if err != nil { + t.Fatalf("recall after: %v", err) + } + staleAfter, freshAfter := scoreOf(after.Results, "stale"), scoreOf(after.Results, "fresh") + + if staleAfter >= freshAfter { + t.Errorf("after supersedes edge the correction must outrank the corrected row: stale=%.4f fresh=%.4f", staleAfter, freshAfter) + } + if staleAfter >= staleBefore { + t.Errorf("superseded row was not demoted: before=%.4f after=%.4f", staleBefore, staleAfter) + } + + // Demotion, not deletion: the row stays reachable and is flagged. + var found bool + for _, r := range after.Results { + if r.Insight.ID == "stale" { + found = true + if !r.Superseded { + t.Error("superseded row must be flagged Superseded=true") + } + } + } + if !found { + t.Error("superseded row must remain retrievable, not be filtered out") + } +} + +func scoreOf(results []RecallResult, id string) float64 { + for _, r := range results { + if r.Insight.ID == id { + return r.Score + } + } + return 0 +} diff --git a/internal/memory/search/recall.go b/internal/memory/search/recall.go index c99f9906..ebe70ed8 100644 --- a/internal/memory/search/recall.go +++ b/internal/memory/search/recall.go @@ -2,6 +2,7 @@ package search import ( "container/heap" + "fmt" "math" "sort" "strings" @@ -75,6 +76,14 @@ const ( rerankGraphNoEmbed = 0.30 ) +// supersededScoreFactor multiplies the final score of an insight that some +// other insight claims to supersede. It is a demotion, not a filter: the row +// stays reachable (lineage, audit, explicit recall) but stops outranking the +// content that replaced it. Without this, a correction competes with the text +// it corrects on similarity alone and routinely loses, because the stale row +// is older and therefore better connected and more frequently accessed. +const supersededScoreFactor = 0.25 + // SignalScores holds the individual reranking signal scores for a result. type SignalScores struct { Keyword float64 `json:"keyword"` @@ -105,6 +114,10 @@ type RecallResult struct { Intent Intent `json:"intent"` Via string `json:"via,omitempty"` Signals SignalScores `json:"signals"` + // Superseded reports that another insight claims to replace this one. + // Surfaced so a caller can see why a result ranked low, and so an agent + // reading raw results is not silently handed stale content. + Superseded bool `json:"superseded,omitempty"` } // IntentAwareRecall performs MAGMA-aligned intent-aware retrieval: @@ -350,9 +363,25 @@ func IntentAwareRecall(db *store.DB, query string, queryVec []float64, wKw, wEnt, wSim, wGr = rerankKeywordNoEmbed, rerankEntityNoEmbed, 0, rerankGraphNoEmbed } + // An insight that another insight claims to supersede is demoted before + // ranking, and only the candidates in hand are looked up. A failed lookup + // must not serve corrected content without its superseded marker. + candidateIDs := make([]string, len(candidates)) + for i := range candidates { + candidateIDs[i] = candidates[i].id + } + superseded, supersededErr := db.GetSupersededIDs(candidateIDs) + if supersededErr != nil { + return RecallResponse{}, fmt.Errorf("lookup superseded insights: %w", supersededErr) + } + results := make([]RecallResult, 0, len(candidates)) for _, c := range candidates { finalScore := wKw*c.kwScore + wEnt*c.entScore + wSim*c.simScore + wGr*c.graphScore + isSuperseded := superseded[c.id] + if isSuperseded { + finalScore *= supersededScoreFactor + } results = append(results, RecallResult{ Insight: c.ins, Score: finalScore, @@ -364,6 +393,7 @@ func IntentAwareRecall(db *store.DB, query string, queryVec []float64, Similarity: c.simScore, Graph: c.graphScore, }, + Superseded: isSuperseded, }) } diff --git a/internal/memory/search/supersedes_test.go b/internal/memory/search/supersedes_test.go new file mode 100644 index 00000000..a24bf270 --- /dev/null +++ b/internal/memory/search/supersedes_test.go @@ -0,0 +1,42 @@ +package search + +import ( + "errors" + "strings" + "testing" + "time" + + "github.com/mnemon-dev/mnemon/internal/memory/model" + "modernc.org/sqlite" +) + +func TestIntentAwareRecallReportsSupersededLookupFailure(t *testing.T) { + db := testDB(t) + insertInsight(t, db, "stale", "release cache ttl defaults to thirty days", "test", 3, nil, time.Now().UTC()) + insertInsight(t, db, "fresh", "release cache ttl was corrected: use seven days", "test", 3, nil, time.Now().UTC()) + if err := db.InsertEdge(&model.Edge{ + SourceID: "fresh", TargetID: "stale", EdgeType: model.EdgeSupersedes, + Weight: 1, CreatedAt: time.Now().UTC(), + }); err != nil { + t.Fatal(err) + } + // Keep the candidate insights available, but model a partially recovered + // SQLite schema in which the authority lookup cannot read its table. + if _, err := db.Conn().Exec(`ALTER TABLE edges RENAME TO preserved_edges`); err != nil { + t.Fatal(err) + } + response, err := IntentAwareRecall(db, "release cache ttl thirty days", nil, nil, 5, nil) + if err == nil { + t.Fatalf("recall silently served %d results without verifying supersession", len(response.Results)) + } + if !strings.Contains(err.Error(), "lookup superseded insights") { + t.Fatalf("missing lookup context: %v", err) + } + var sqliteErr *sqlite.Error + if !errors.As(err, &sqliteErr) { + t.Fatalf("underlying SQLite error was not preserved: %v", err) + } + if len(response.Results) != 0 { + t.Fatalf("failed recall returned %d unverified results", len(response.Results)) + } +} diff --git a/internal/memory/store/db.go b/internal/memory/store/db.go index a592bd87..9cb57b99 100644 --- a/internal/memory/store/db.go +++ b/internal/memory/store/db.go @@ -2,6 +2,7 @@ package store import ( "database/sql" + "errors" "fmt" "math" "net/url" @@ -12,7 +13,8 @@ import ( "strings" "github.com/mnemon-dev/mnemon/internal/memory/embed" - _ "modernc.org/sqlite" + "modernc.org/sqlite" + sqlite3 "modernc.org/sqlite/lib" ) // DefaultStoreName is the fallback store when none is specified. @@ -296,7 +298,7 @@ CREATE TABLE IF NOT EXISTS insights ( CREATE TABLE IF NOT EXISTS edges ( source_id TEXT NOT NULL, target_id TEXT NOT NULL, - edge_type TEXT NOT NULL CHECK(edge_type IN ('temporal','semantic','causal','entity')), + edge_type TEXT NOT NULL CHECK(edge_type IN ('temporal','semantic','causal','entity','supersedes')), weight REAL DEFAULT 1.0, metadata TEXT DEFAULT '{}', created_at TEXT NOT NULL, @@ -390,6 +392,13 @@ CREATE INDEX IF NOT EXISTS idx_oplog_created ON oplog(created_at); return fmt.Errorf("remove narrative edges: %w", err) } + // Migration: widen the edge CHECK to admit the 'supersedes' type. Must run + // after migrateRemoveNarrativeEdges, which rebuilds the table with the + // older four-type constraint. + if err := db.migrateAddSupersedesEdgeType(); err != nil { + return fmt.Errorf("add supersedes edge type: %w", err) + } + // One-time cleanup: soft-delete narrative category insights from legacy databases. // Only runs the UPDATE when narrative insights actually exist (avoids needless writes). var narrativeCount int @@ -578,3 +587,82 @@ func (db *DB) migrateRemoveNarrativeEdges() error { } return tx.Commit() } + +// migrateAddSupersedesEdgeType widens the edges CHECK constraint to admit the +// 'supersedes' type. SQLite cannot alter a CHECK in place, so the table is +// rebuilt; existing rows are copied verbatim and no edge is created or +// dropped. +func (db *DB) migrateAddSupersedesEdgeType() error { + // Enforcement stays off for the probe as well as the rebuild. The probe + // inserts a sentinel edge whose endpoints do not exist, so with + // foreign_keys(1) it would fail on the foreign key rather than the CHECK + // and report "not yet migrated" on every open -- rebuilding the whole + // table each time. The rebuild needs it for the same reason as the + // narrative migration above: a dangling edge must not abort the copy. + if _, err := db.conn.Exec(`PRAGMA foreign_keys=OFF`); err != nil { + return fmt.Errorf("disable foreign keys for edge rebuild: %w", err) + } + defer func() { _, _ = db.conn.Exec(`PRAGMA foreign_keys=ON`) }() + + // The probe is rolled back, always. Were it left in autocommit -- which is + // what disabling enforcement above lets succeed -- the sentinel row would + // outlive a rebuild that fails or a process that dies mid-migration. The + // next open's probe would then collide with the leftover on the primary + // key, and a failing probe is read below as "not yet migrated", so the + // whole edges table would be rebuilt on that open and on every one after + // it. Nothing here deletes a sentinel afterwards; the rollback is what + // guarantees there is never one to delete. + // + // The id is random rather than a fixed '__probe' because insight ids are + // caller-supplied, and a fixed sentinel can collide with a real row. + probeTx, err := db.conn.Begin() + if err != nil { + return fmt.Errorf("begin probe: %w", err) + } + _, probeErr := probeTx.Exec( + `INSERT INTO edges VALUES ('__probe_'||hex(randomblob(8)),'__probe_'||hex(randomblob(8)),'supersedes',0,'{}',datetime('now'))`) + if err := probeTx.Rollback(); err != nil { + return fmt.Errorf("roll back probe: %w", err) + } + if probeErr == nil { + return nil // already migrated + } + var sqliteErr *sqlite.Error + if !errors.As(probeErr, &sqliteErr) || sqliteErr.Code() != sqlite3.SQLITE_CONSTRAINT_CHECK { + return fmt.Errorf("probe supersedes edge type: %w", probeErr) + } + + tx, err := db.conn.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + steps := []string{ + `ALTER TABLE edges RENAME TO edges_old`, + `CREATE TABLE edges ( + source_id TEXT NOT NULL, + target_id TEXT NOT NULL, + edge_type TEXT NOT NULL CHECK(edge_type IN ('temporal','semantic','causal','entity','supersedes')), + weight REAL DEFAULT 1.0, + metadata TEXT DEFAULT '{}', + created_at TEXT NOT NULL, + PRIMARY KEY (source_id, target_id, edge_type), + FOREIGN KEY (source_id) REFERENCES insights(id) ON DELETE CASCADE, + FOREIGN KEY (target_id) REFERENCES insights(id) ON DELETE CASCADE + )`, + `INSERT INTO edges SELECT * FROM edges_old`, + `DROP TABLE edges_old`, + `CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source_id)`, + `CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target_id)`, + `CREATE INDEX IF NOT EXISTS idx_edges_type ON edges(edge_type)`, + `CREATE INDEX IF NOT EXISTS idx_edges_source_type ON edges(source_id, edge_type)`, + `CREATE INDEX IF NOT EXISTS idx_edges_target_type ON edges(target_id, edge_type)`, + } + for _, s := range steps { + if _, err := tx.Exec(s); err != nil { + return fmt.Errorf("step %q: %w", s[:min(len(s), 40)], err) + } + } + return tx.Commit() +} diff --git a/internal/memory/store/edge.go b/internal/memory/store/edge.go index 78c03793..8dca1aa8 100644 --- a/internal/memory/store/edge.go +++ b/internal/memory/store/edge.go @@ -2,6 +2,7 @@ package store import ( "fmt" + "strings" "time" "github.com/mnemon-dev/mnemon/internal/memory/model" @@ -9,6 +10,9 @@ import ( // InsertEdge inserts or replaces an edge. func (db *DB) InsertEdge(e *model.Edge) error { + if e.EdgeType == model.EdgeSupersedes && e.SourceID == e.TargetID { + return fmt.Errorf("supersedes requires distinct insights") + } _, err := db.execer().Exec( `INSERT OR REPLACE INTO edges (source_id, target_id, edge_type, weight, metadata, created_at) VALUES (?, ?, ?, ?, ?, ?)`, @@ -35,6 +39,64 @@ func (db *DB) GetEdgesByNode(nodeID string) ([]*model.Edge, error) { return scanEdges(rows) } +// supersededLookupChunk bounds how many ids go into one IN clause. SQLite's +// host-parameter ceiling is 32766 on current builds and 999 on older ones; +// 500 stays inside both. Recall's candidate set is normally far smaller, so +// the loop below runs once. +const supersededLookupChunk = 500 + +// GetSupersededIDs returns which of the given ids are the target of at least +// one 'supersedes' edge, i.e. which of them some other insight claims to +// replace. Recall uses this to demote stale content; the rows are kept so the +// lineage stays inspectable. +// +// The lookup is scoped to the ids the caller holds. Reading every supersedes +// edge in the store would cost time proportional to its whole supersession +// history on a path that only needs a verdict for the current candidates, +// and idx_edges_target_type answers the scoped form from the index. +func (db *DB) GetSupersededIDs(ids []string) (map[string]bool, error) { + superseded := make(map[string]bool) + ex := db.execer() + for start := 0; start < len(ids); start += supersededLookupChunk { + end := min(start+supersededLookupChunk, len(ids)) + if err := collectSupersededIDs(ex, ids[start:end], superseded); err != nil { + return nil, err + } + } + return superseded, nil +} + +// collectSupersededIDs adds the superseded ids in one batch to into. The rows +// are closed before returning: the pool holds a single connection, so an open +// cursor would block the next batch. +func collectSupersededIDs(ex dbExecer, chunk []string, into map[string]bool) error { + args := make([]any, 0, len(chunk)+1) + args = append(args, string(model.EdgeSupersedes)) + placeholders := make([]string, len(chunk)) + for i, id := range chunk { + placeholders[i] = "?" + args = append(args, id) + } + + rows, err := ex.Query(fmt.Sprintf( + `SELECT DISTINCT target_id FROM edges + WHERE edge_type = ? AND source_id != target_id AND target_id IN (%s)`, + strings.Join(placeholders, ",")), args...) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return err + } + into[id] = true + } + return rows.Err() +} + // GetEdgesByNodeAndType returns edges for a node filtered by edge type. // Uses UNION ALL to allow SQLite to use composite indexes. func (db *DB) GetEdgesByNodeAndType(nodeID string, edgeType model.EdgeType) ([]*model.Edge, error) { diff --git a/internal/memory/store/store_test.go b/internal/memory/store/store_test.go index ba19ecda..fd3932b6 100644 --- a/internal/memory/store/store_test.go +++ b/internal/memory/store/store_test.go @@ -4,6 +4,7 @@ import ( "bytes" "database/sql" "encoding/binary" + "fmt" "math" "os" "path/filepath" @@ -784,6 +785,205 @@ func TestMigrateStoredAtBackfillsLegacyInsights(t *testing.T) { } } +// The supersedes migration probes by inserting a sentinel edge whose endpoints +// do not exist. If that probe runs with foreign key enforcement on it fails on +// the foreign key rather than the CHECK, reports "not yet migrated" every +// time, and rebuilds the entire edges table on every single open. Opening +// twice must leave the table alone the second time. +func TestMigrateAddSupersedesEdgeType_IsIdempotent(t *testing.T) { + dir := t.TempDir() + + first, err := Open(dir) + if err != nil { + t.Fatalf("first open: %v", err) + } + if err := first.InsertInsight(makeInsight("sup-a", "content", 3)); err != nil { + t.Fatalf("insert: %v", err) + } + if err := first.InsertEdge(&model.Edge{ + SourceID: "sup-a", TargetID: "sup-a", EdgeType: model.EdgeSemantic, + Weight: 0.5, Metadata: map[string]string{}, CreatedAt: time.Now().UTC(), + }); err != nil { + t.Fatalf("insert edge: %v", err) + } + var rootBefore int + if err := first.conn.QueryRow( + `SELECT rootpage FROM sqlite_master WHERE name = 'edges'`).Scan(&rootBefore); err != nil { + t.Fatalf("rootpage before: %v", err) + } + first.Close() + + second, err := Open(dir) + if err != nil { + t.Fatalf("second open: %v", err) + } + defer second.Close() + + var rootAfter int + if err := second.conn.QueryRow( + `SELECT rootpage FROM sqlite_master WHERE name = 'edges'`).Scan(&rootAfter); err != nil { + t.Fatalf("rootpage after: %v", err) + } + if rootAfter != rootBefore { + t.Errorf("edges table was rebuilt on reopen (rootpage %d -> %d); the probe is misreporting", rootBefore, rootAfter) + } + + if n := countProbeRows(t, second); n != 0 { + t.Errorf("probe rows leaked into edges: %d", n) + } +} + +// disallowSupersedesEdges restores the historical edges schema, which predates +// the 'supersedes' edge type, by rewriting the stored CHECK constraint in +// place. +func disallowSupersedesEdges(t *testing.T, db *DB) { + t.Helper() + for _, s := range []string{ + `PRAGMA writable_schema=ON`, + `UPDATE sqlite_master SET sql = replace(sql, ",'supersedes'", "") WHERE name = 'edges'`, + `PRAGMA writable_schema=OFF`, + } { + if _, err := db.conn.Exec(s); err != nil { + t.Fatalf("%s: %v", s, err) + } + } +} + +// countProbeRows counts sentinel rows a schema probe could have left behind, +// on either endpoint. The prefix is deliberately narrow: '__test' is a real +// insight id elsewhere in this file, so matching every id beginning '__' +// would report legitimate data as a leak. +func countProbeRows(t *testing.T, db *DB) int { + t.Helper() + var n int + if err := db.conn.QueryRow( + `SELECT COUNT(*) FROM edges + WHERE source_id LIKE '\_\_probe\_%' ESCAPE '\' + OR target_id LIKE '\_\_probe\_%' ESCAPE '\'`).Scan(&n); err != nil { + t.Fatalf("count probes: %v", err) + } + return n +} + +// Idempotence alone only exercises the early return. A store written before +// the type existed must actually be rebuilt: 'supersedes' accepted afterwards, +// every existing edge carried over verbatim, and no probe row left behind. +// +// sqlite_master rewrites are not visible on the connection that wrote them, so +// the first handle is kept open (its CHECK still admits 'supersedes') while a +// second Open reads the on-disk four-type schema and runs the migration. +func TestMigrateAddSupersedesEdgeType_UpgradesLegacySchema(t *testing.T) { + dir := t.TempDir() + + first, err := Open(dir) + if err != nil { + t.Fatalf("open: %v", err) + } + defer first.Close() + if err := first.InsertInsight(makeInsight("legacy-a", "content", 3)); err != nil { + t.Fatalf("insert insight: %v", err) + } + if err := first.InsertEdge(&model.Edge{ + SourceID: "legacy-a", TargetID: "legacy-a", EdgeType: model.EdgeSemantic, + Weight: 0.5, Metadata: map[string]string{}, CreatedAt: time.Now().UTC(), + }); err != nil { + t.Fatalf("insert edge: %v", err) + } + disallowSupersedesEdges(t, first) + + var sqlText string + if err := first.conn.QueryRow( + `SELECT sql FROM sqlite_master WHERE name = 'edges'`).Scan(&sqlText); err != nil { + t.Fatalf("read schema: %v", err) + } + if !strings.Contains(sqlText, "'entity')") || strings.Contains(sqlText, "supersedes") { + t.Fatalf("on-disk CHECK was not narrowed: %s", sqlText) + } + + reopened, err := Open(dir) + if err != nil { + t.Fatalf("reopen: %v", err) + } + defer reopened.Close() + + if _, err := reopened.conn.Exec( + `INSERT INTO edges VALUES ('legacy-a','legacy-a','supersedes',1,'{}','2026-01-01T00:00:00Z')`); err != nil { + t.Errorf("supersedes edge type must be admitted after migration: %v", err) + } + + var kept int + if err := reopened.conn.QueryRow( + `SELECT COUNT(*) FROM edges WHERE source_id = 'legacy-a' AND edge_type = 'semantic'`).Scan(&kept); err != nil { + t.Fatalf("count kept: %v", err) + } + if kept != 1 { + t.Errorf("the rebuild dropped a pre-existing edge, got %d", kept) + } + + if n := countProbeRows(t, reopened); n != 0 { + t.Errorf("probe rows leaked into edges: %d", n) + } +} + +// --- supersedes lookup --- + +// The lookup answers about the ids it is given, not about the whole store: an +// insight superseded elsewhere must not surface in a verdict on a different +// candidate set. The oversized slice also crosses the batching boundary, so a +// match found after the first chunk must still be reported. +func TestGetSupersededIDs_ScopesToRequestedIDs(t *testing.T) { + db := testDB(t) + + for _, id := range []string{"sup-stale", "sup-fresh", "sup-other-stale", "sup-other-fresh"} { + if err := db.InsertInsight(makeInsight(id, "content", 3)); err != nil { + t.Fatalf("insert %s: %v", id, err) + } + } + supersede := func(fresh, stale string) { + t.Helper() + if err := db.InsertEdge(&model.Edge{ + SourceID: fresh, TargetID: stale, EdgeType: model.EdgeSupersedes, + Weight: 1, Metadata: map[string]string{}, CreatedAt: time.Now().UTC(), + }); err != nil { + t.Fatalf("supersede %s -> %s: %v", fresh, stale, err) + } + } + supersede("sup-fresh", "sup-stale") + supersede("sup-other-fresh", "sup-other-stale") + + got, err := db.GetSupersededIDs([]string{"sup-stale", "sup-fresh"}) + if err != nil { + t.Fatalf("lookup: %v", err) + } + if !got["sup-stale"] { + t.Error("sup-stale is the target of a supersedes edge and must be reported") + } + if len(got) != 1 { + t.Errorf("lookup answered about ids the caller never asked for: %v", got) + } + + many := make([]string, 0, supersededLookupChunk+2) + for i := 0; i <= supersededLookupChunk; i++ { + many = append(many, fmt.Sprintf("absent-%d", i)) + } + many = append(many, "sup-other-stale") + got, err = db.GetSupersededIDs(many) + if err != nil { + t.Fatalf("chunked lookup: %v", err) + } + if !got["sup-other-stale"] { + t.Error("a match past the first chunk was dropped") + } + + empty, err := db.GetSupersededIDs(nil) + if err != nil { + t.Fatalf("empty lookup: %v", err) + } + if len(empty) != 0 { + t.Errorf("no ids asked about, got %v", empty) + } +} + // --- AutoPrune --- func disableAutoPruneGrace(t *testing.T) { diff --git a/internal/memory/store/supersedes_migration_test.go b/internal/memory/store/supersedes_migration_test.go new file mode 100644 index 00000000..a539867e --- /dev/null +++ b/internal/memory/store/supersedes_migration_test.go @@ -0,0 +1,162 @@ +package store + +import ( + "database/sql" + "path/filepath" + "strings" + "testing" + + "github.com/mnemon-dev/mnemon/internal/memory/model" +) + +func TestSupersedesUpgradePreservesClosedLegacyDatabase(t *testing.T) { + for _, orphan := range []bool{false, true} { + name := "valid" + if orphan { + name = "existing orphan" + } + t.Run(name, func(t *testing.T) { + dir := createLegacySupersedesFixture(t, orphan) + db, err := Open(dir) + if err != nil { + t.Fatal(err) + } + edges, err := db.GetAllEdges() + wantEdges := 4 + if orphan { + wantEdges++ + } + if err != nil || len(edges) != wantEdges { + t.Fatalf("legacy edges lost: %v, %v", edges, err) + } + for _, edge := range edges { + if edge.Weight != 0.375 || edge.Metadata["audit"] != "原文" || edge.CreatedAt.Format("2006-01-02T15:04:05Z") != "2026-01-01T01:02:03Z" { + t.Fatalf("legacy edge payload changed: %+v", edge) + } + } + var root, schemaVersion int + if err := db.Conn().QueryRow(`SELECT rootpage FROM sqlite_master WHERE name='edges'`).Scan(&root); err != nil { + t.Fatal(err) + } + if err := db.Conn().QueryRow(`PRAGMA schema_version`).Scan(&schemaVersion); err != nil { + t.Fatal(err) + } + if err := db.InsertEdge(&model.Edge{SourceID: "a", TargetID: "b", EdgeType: model.EdgeSupersedes, Weight: 1}); err != nil { + t.Fatalf("upgraded schema rejects supersedes: %v", err) + } + if err := db.InsertEdge(&model.Edge{SourceID: "missing-new", TargetID: "b", EdgeType: model.EdgeSupersedes}); err == nil { + t.Fatal("migration did not restore foreign key enforcement") + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + checkSupersedesReopens(t, dir, root, schemaVersion, wantEdges+1, orphan) + }) + } +} + +func createLegacySupersedesFixture(t *testing.T, orphan bool) string { + t.Helper() + dir := t.TempDir() + conn, err := sql.Open("sqlite", filepath.Join(dir, "mnemon.db")) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + // Construct an actual historical file, then close it before Open reads its + // schema. Do not rewrite sqlite_master or rely on a stale connection cache. + if _, err := conn.Exec(`CREATE TABLE insights ( + id TEXT PRIMARY KEY, content TEXT NOT NULL, category TEXT DEFAULT 'general', + importance INTEGER DEFAULT 3, tags TEXT DEFAULT '[]', entities TEXT DEFAULT '[]', + source TEXT DEFAULT 'user', access_count INTEGER DEFAULT 0, + created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT); + INSERT INTO insights(id,content,created_at,updated_at) VALUES + ('a','保留旧记忆','2026-01-01T00:00:00Z','2026-01-01T00:00:00Z'), + ('b','retain another memory','2026-01-01T00:00:00Z','2026-01-01T00:00:00Z'); + CREATE TABLE edges ( + source_id TEXT NOT NULL, target_id TEXT NOT NULL, + edge_type TEXT NOT NULL CHECK(edge_type IN ('temporal','semantic','causal','entity')), + weight REAL DEFAULT 1.0, metadata TEXT DEFAULT '{}', created_at TEXT NOT NULL, + PRIMARY KEY(source_id,target_id,edge_type), + FOREIGN KEY(source_id) REFERENCES insights(id) ON DELETE CASCADE, + FOREIGN KEY(target_id) REFERENCES insights(id) ON DELETE CASCADE);`); err != nil { + t.Fatal(err) + } + for _, edgeType := range []string{"temporal", "semantic", "causal", "entity"} { + if _, err := conn.Exec(`INSERT INTO edges VALUES ('a','b',?,0.375,'{"audit":"原文"}','2026-01-01T01:02:03Z')`, edgeType); err != nil { + t.Fatal(err) + } + } + if orphan { + if _, err := conn.Exec(`INSERT INTO edges VALUES ('missing-old','a','semantic',0.375,'{"audit":"原文"}','2026-01-01T01:02:03Z')`); err != nil { + t.Fatal(err) + } + } + return dir +} + +func checkSupersedesReopens(t *testing.T, dir string, root, schemaVersion, edgeCount int, orphan bool) { + t.Helper() + for attempt := 0; attempt < 3; attempt++ { + db, err := Open(dir) + if err != nil { + t.Fatal(err) + } + var gotRoot, gotVersion int + if err := db.Conn().QueryRow(`SELECT rootpage FROM sqlite_master WHERE name='edges'`).Scan(&gotRoot); err != nil { + t.Fatal(err) + } + if err := db.Conn().QueryRow(`PRAGMA schema_version`).Scan(&gotVersion); err != nil { + t.Fatal(err) + } + if gotRoot != root || gotVersion != schemaVersion { + t.Fatalf("reopen rebuilt schema: root %d→%d version %d→%d", root, gotRoot, schemaVersion, gotVersion) + } + var integrity string + if err := db.Conn().QueryRow(`PRAGMA integrity_check`).Scan(&integrity); err != nil || integrity != "ok" { + t.Fatalf("integrity: %s, %v", integrity, err) + } + var fkViolations, gotEdges int + if err := db.Conn().QueryRow(`SELECT count(*) FROM pragma_foreign_key_check`).Scan(&fkViolations); err != nil { + t.Fatal(err) + } + wantFK := 0 + if orphan { + wantFK = 1 + } + if fkViolations != wantFK { + t.Fatalf("foreign key violations changed: %d, want %d", fkViolations, wantFK) + } + if err := db.Conn().QueryRow(`SELECT count(*) FROM edges`).Scan(&gotEdges); err != nil || gotEdges != edgeCount { + t.Fatalf("edges after reopen: %d, %v", gotEdges, err) + } + ins, err := db.GetInsightByID("a") + if err != nil || ins.Content != "保留旧记忆" || countProbeRows(t, db) != 0 { + t.Fatalf("legacy insight or probe invariant changed: %v, %v", ins, err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + } +} + +func TestSupersedesMigrationRejectsUnexpectedProbeFailure(t *testing.T) { + db := testDB(t) + if _, err := db.Conn().Exec(`CREATE TRIGGER edge_guard BEFORE INSERT ON edges + BEGIN SELECT RAISE(ABORT, 'edge guard denied insert'); END`); err != nil { + t.Fatal(err) + } + // The widened schema is already present. A trigger failure is not evidence + // that supersedes violates its CHECK and must never authorize a rebuild. + err := db.migrateAddSupersedesEdgeType() + if err == nil || !strings.Contains(err.Error(), "probe supersedes edge type") { + t.Errorf("unexpected probe failure must be reported: %v", err) + } + var guards, foreignKeys int + if err := db.Conn().QueryRow(`SELECT count(*) FROM sqlite_master WHERE name='edge_guard'`).Scan(&guards); err != nil || guards != 1 { + t.Errorf("probe failure removed the existing guard: %d, %v", guards, err) + } + if err := db.Conn().QueryRow(`PRAGMA foreign_keys`).Scan(&foreignKeys); err != nil || foreignKeys != 1 { + t.Errorf("foreign keys were not restored after failure: %d, %v", foreignKeys, err) + } +} diff --git a/internal/memory/store/supersedes_test.go b/internal/memory/store/supersedes_test.go new file mode 100644 index 00000000..a11f7f68 --- /dev/null +++ b/internal/memory/store/supersedes_test.go @@ -0,0 +1,55 @@ +package store + +import ( + "strings" + "testing" + "time" + + "github.com/mnemon-dev/mnemon/internal/memory/model" +) + +func TestInsertEdgeRejectsSelfSupersession(t *testing.T) { + db := testDB(t) + if err := db.InsertInsight(makeInsight("only", "one current fact", 3)); err != nil { + t.Fatal(err) + } + edge := &model.Edge{ + SourceID: "only", TargetID: "only", EdgeType: model.EdgeSupersedes, + Weight: 1, CreatedAt: time.Now().UTC(), + } + if err := db.InsertEdge(edge); err == nil || !strings.Contains(err.Error(), "distinct insights") { + t.Fatalf("self supersession must be rejected: %v", err) + } + edges, err := db.GetAllEdges() + if err != nil || len(edges) != 0 { + t.Fatalf("invalid relation was persisted: edges=%v err=%v", edges, err) + } + // Existing edge types keep their previous behavior. + edge.EdgeType = model.EdgeSemantic + if err := db.InsertEdge(edge); err != nil { + t.Fatalf("semantic self edge: %v", err) + } +} + +func TestGetSupersededIDsIgnoresExistingSelfEdges(t *testing.T) { + db := testDB(t) + for _, id := range []string{"self", "stale", "fresh"} { + if err := db.InsertInsight(makeInsight(id, "current fact", 3)); err != nil { + t.Fatal(err) + } + } + // Older writers and recovered stores can contain self edges. Retain the + // row for audit, but it cannot assert replacement by another insight. + if _, err := db.Conn().Exec(`INSERT INTO edges VALUES + ('self','self','supersedes',1,'{}','2026-01-01T00:00:00Z'), + ('fresh','stale','supersedes',1,'{}','2026-01-01T00:00:00Z')`); err != nil { + t.Fatal(err) + } + got, err := db.GetSupersededIDs([]string{"self", "stale", "fresh"}) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || !got["stale"] { + t.Fatalf("only a distinct replacement can supersede an insight: %v", got) + } +}