Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions cmd/memory/import_diff_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"testing"
"time"

"github.com/mnemon-dev/mnemon/internal/memory/importdraft"
"github.com/mnemon-dev/mnemon/internal/memory/model"
Expand Down Expand Up @@ -155,3 +156,156 @@ func TestImportNoDiffStoresExactRepeats(t *testing.T) {
result.Results[0].ID: content, result.Results[1].ID: content,
})
}

// Mainline now preserves every non-identical fact, including UPDATE suggestions.
// Keep the conflict review's persistence and edge checks under that write policy.
func TestImportDiffWriteOutcomes(t *testing.T) {
tests := []struct {
name string
existing string
newText string
noDiff bool
wantAction string
wantActive int
wantDeleted bool
}{
{"exact duplicate", "Production deployment is allowed", "Production deployment is allowed", false, "skipped", 1, false},
{"negated correction", "Production deployment is allowed", "Production deployment is not allowed", false, "added", 2, false},
{"existing conflict signal", "Production deployment supports Python services", "Production deployment no longer supports Python services", false, "added", 2, false},
{"ordinary update is preserved", "Production deployment uses PostgreSQL for persistent storage", "Production deployment uses SQLite for persistent storage", false, "added", 2, false},
{"no diff inserts duplicate", "Production deployment is allowed", "Production deployment is allowed", true, "added", 2, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db := setupImportDiffTest(t, tt.noDiff)
insertTestInsight(t, db, "original", tt.existing, "original-source", "2026-01-01T00:00:00Z")
summary := runImportDiffDraft(t, importdraft.MemoryDraft{
SchemaVersion: "1",
Insights: []importdraft.DraftInsight{{Content: tt.newText, Importance: 5}},
})
if summary.Errors != 0 || len(summary.Results) != 1 || summary.Results[0].Action != tt.wantAction {
t.Fatalf("import summary = %+v, want one %s result", summary, tt.wantAction)
}
counts := map[string]int{"added": summary.Imported, "updated": summary.Updated, "skipped": summary.Skipped}
if counts[tt.wantAction] != 1 || summary.Imported+summary.Updated+summary.Skipped != 1 {
t.Fatalf("import counts = %v, want one %s", counts, tt.wantAction)
}
active, err := db.GetAllActiveInsights()
if err != nil || len(active) != tt.wantActive {
t.Fatalf("active insights = %d, error = %v; want %d", len(active), err, tt.wantActive)
}
original, err := db.GetInsightByIDIncludeDeleted("original")
if err != nil {
t.Fatal(err)
}
if deleted := original.DeletedAt != nil; deleted != tt.wantDeleted {
t.Fatalf("original deleted = %v, want %v", deleted, tt.wantDeleted)
}
resultID := summary.Results[0].ID
if (resultID == "original") != (tt.wantAction == "skipped") {
t.Fatalf("result ID = %q for action %s", resultID, tt.wantAction)
}
result, err := db.GetInsightByID(resultID)
if err != nil || result.Content != tt.newText {
t.Fatalf("imported insight = %+v, error = %v", result, err)
}
})
}
}

func TestImportConflictAndDuplicateResolveExplicitEdges(t *testing.T) {
db := setupImportDiffTest(t, false)
insertTestInsight(t, db, "original", "Production deployment is allowed", "original-source", "2026-01-01T00:00:00Z")
insertTestInsight(t, db, "context", "Security review approved the release plan", "review-source", "2026-01-01T00:00:00Z")
if err := db.InsertEdge(&model.Edge{
SourceID: "original", TargetID: "context", EdgeType: model.EdgeCausal,
Weight: 0.7, CreatedAt: time.Now().UTC(),
}); err != nil {
t.Fatal(err)
}
summary := runImportDiffDraft(t, importdraft.MemoryDraft{
SchemaVersion: "1",
Insights: []importdraft.DraftInsight{
{Content: "Production deployment is allowed", Importance: 5},
{Content: "Production deployment is not allowed", Importance: 5},
},
Edges: []importdraft.DraftEdge{{SourceIndex: 1, TargetIndex: 0, EdgeType: "causal", Weight: 0.8}},
})
if summary.Errors != 0 || summary.Imported != 1 || summary.Updated != 0 || summary.Skipped != 1 || summary.EdgesInserted != 1 || len(summary.Results) != 2 {
t.Fatalf("unexpected summary: %+v", summary)
}
if summary.Results[0].ID != "original" || summary.Results[0].Action != "skipped" || summary.Results[1].Action != "added" {
t.Fatalf("draft indices resolved incorrectly: %+v", summary.Results)
}
active, err := db.GetAllActiveInsights()
if err != nil || len(active) != 3 {
t.Fatalf("active insights = %d, error = %v; want 3", len(active), err)
}
for _, pair := range [][2]string{{summary.Results[1].ID, "original"}, {"original", "context"}} {
edges, err := db.GetEdgesBySourceAndType(pair[0], model.EdgeCausal)
if err != nil {
t.Fatal(err)
}
found := false
for _, edge := range edges {
if edge.TargetID == pair[1] {
found = true
}
}
if !found {
t.Fatalf("missing causal edge %s -> %s", pair[0], pair[1])
}
}
}

type importDiffSummary struct {
Imported int `json:"imported"`
Updated int `json:"updated"`
Skipped int `json:"skipped"`
Errors int `json:"errors"`
EdgesInserted int `json:"edges_inserted"`
Results []importResult `json:"results"`
}

func setupImportDiffTest(t *testing.T, noDiff bool) *store.DB {
t.Helper()
t.Setenv("MNEMON_EMBED_ENDPOINT", "http://127.0.0.1:1")
t.Setenv("MNEMON_EMBED_PROTOCOL", "ollama")
t.Setenv("MNEMON_MAX_INSIGHTS", "1000")
oldDataDir, oldStoreName, oldReadOnly := dataDir, storeName, readOnly
oldImportNoDiff, oldImportDryRun := importNoDiff, importDryRun
t.Cleanup(func() {
dataDir, storeName, readOnly = oldDataDir, oldStoreName, oldReadOnly
importNoDiff, importDryRun = oldImportNoDiff, oldImportDryRun
})
dataDir, storeName, readOnly = t.TempDir(), store.DefaultStoreName, false
importNoDiff, importDryRun = noDiff, false
db, err := store.Open(store.StoreDir(dataDir, storeName))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
return db
}

func runImportDiffDraft(t *testing.T, draft importdraft.MemoryDraft) importDiffSummary {
t.Helper()
data, err := json.Marshal(draft)
if err != nil {
t.Fatal(err)
}
draftPath := filepath.Join(t.TempDir(), "draft.json")
if err := os.WriteFile(draftPath, data, 0o600); err != nil {
t.Fatal(err)
}
output := captureStdout(t, func() {
if err := importCmd.RunE(importCmd, []string{draftPath}); err != nil {
t.Fatal(err)
}
})
var summary importDiffSummary
if err := json.Unmarshal([]byte(output), &summary); err != nil {
t.Fatalf("decode summary: %v\n%s", err, output)
}
return summary
}
12 changes: 12 additions & 0 deletions cmd/memory/remember_diff_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,18 @@ func TestRememberPreservesDistinctContent(t *testing.T) {
second: "Project Alpha no longer uses PostgreSQL database for persistent application storage",
suggestion: search.DiffConflict,
},
{
name: "near duplicate negation remains advisory",
first: "Production deployment is allowed",
second: "Production deployment is not allowed",
suggestion: search.DiffConflict,
},
{
name: "removing negation remains advisory",
first: "Production deployment is not allowed",
second: "Production deployment is allowed",
suggestion: search.DiffConflict,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down
28 changes: 28 additions & 0 deletions internal/memory/search/diff.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package search

import (
"regexp"
"sort"
"strings"

Expand Down Expand Up @@ -195,6 +196,21 @@ var negationWords = []string{
"不再", "放弃", "替换", "取消",
}

// negationMarkers matches explicit English negation markers in raw text.
// Stopword filtering removes "not"/"no" from the token set, so polarity must be
// read from the original text. Used only to tell a near-identical re-statement
// apart from its negation; it is deliberately NOT part of the >= 0.7 similarity
// conflict scan (bare "not" in scientific prose must not force CONFLICT).
// Unicode word boundaries avoid matching names such as "Noté". Both common
// apostrophes carry the same contraction. Individual CJK characters cannot
// establish negation: "非常" and "未来", for example, are not negative statements.
var negationMarkers = regexp.MustCompile(`(?i)(^|[^\p{L}\p{N}\p{M}_])(not|no|never|cannot|without|none)($|[^\p{L}\p{N}\p{M}_])|n['’]t($|[^\p{L}\p{N}\p{M}_])`)

// hasNegation reports whether text carries an explicit negation marker.
func hasNegation(text string) bool {
return negationMarkers.MatchString(text)
}

func classifySuggestion(tokenSim, similarity float64, newText, existingText string) DiffSuggestion {
if similarity < 0.5 {
return DiffAdd
Expand All @@ -205,10 +221,19 @@ func classifySuggestion(tokenSim, similarity float64, newText, existingText stri
// classified DUPLICATE — a skip would silently drop the new content.
isExtension := len(newText) > len(existingText)+len(existingText)/4

// A near-identical token set can still flip meaning: stopwords strip
// "not"/"no", so "X is allowed" and "X is not allowed" tokenize identically.
// A polarity mismatch on an otherwise near-verbatim re-statement is a
// contradiction to surface (CONFLICT keeps both), never a duplicate to skip.
polarityMismatch := hasNegation(newText) != hasNegation(existingText)

// Near-verbatim re-statement measured by TOKENS (not just embeddings) is a
// duplicate no matter what vocabulary it contains. Checked before the
// negation scan so a text can never "conflict" with a copy of itself.
if tokenSim > 0.9 && !isExtension {
if polarityMismatch {
return DiffConflict
}
return DiffDuplicate
}

Expand All @@ -227,6 +252,9 @@ func classifySuggestion(tokenSim, similarity float64, newText, existingText stri
}

if similarity > 0.9 && !isExtension {
if polarityMismatch {
return DiffConflict
}
return DiffDuplicate
}
return DiffUpdate
Expand Down
78 changes: 78 additions & 0 deletions internal/memory/search/diff_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,3 +188,81 @@ func TestDiff_LowerKeywordScoreUpdateNotMasked(t *testing.T) {
"high-keyword-score ADD from insightA masked the UPDATE", result.Suggestion)
}
}

func TestClassifySuggestion_NegationIsNotDuplicate(t *testing.T) {
// Issue #133: "not" is a stopword, so both texts tokenize identically.
// The negated correction must never be classified DUPLICATE (a skip would
// silently discard it); it must surface as CONFLICT so both facts are kept.
got := classifySuggestion(1.0, 1.0, "Production deployment is not allowed", "Production deployment is allowed")
if got != DiffConflict {
t.Errorf("negated re-statement: want CONFLICT, got %s", got)
}
}

func TestClassifySuggestion_ExactRepetitionStillDuplicate(t *testing.T) {
// Control case: polarity is unchanged, so an exact repetition must still dedupe.
got := classifySuggestion(1.0, 1.0, "Production deployment is allowed", "Production deployment is allowed")
if got != DiffDuplicate {
t.Errorf("exact repetition: want DUPLICATE, got %s", got)
}
}

func TestDiff_NegatedCorrectionIsNotSkipped(t *testing.T) {
// End-to-end through Diff(): the affirmative fact is already stored and the
// negated correction must not be reported as an overall DUPLICATE.
insights := []*model.Insight{
{ID: "1", Content: "Production deployment is allowed"},
}
result := Diff(insights, "Production deployment is not allowed", DiffOptions{})
if result.Suggestion == DiffDuplicate {
t.Errorf("negated correction: overall suggestion must not be DUPLICATE, got %s", result.Suggestion)
}
}

func TestDiff_NegationMarkerBoundaries(t *testing.T) {
const suffix = " for the regional production cluster following security review and automated compliance checks across all services while ensuring observability resilience capacity backups restoration health readiness throughout primary secondary environments"
const chineseSuffix = ",值班团队完成上线审核流程并记录服务状态以及所有关键指标,监控系统会持续观察业务运行情况和生产资源使用情况"
tests := []struct {
name string
existing string
newText string
want DiffSuggestion
}{
{"straight contraction", "Production deployment is allowed" + suffix, "Production deployment isn't allowed" + suffix, DiffConflict},
{"curly contraction", "Production deployment is allowed" + suffix, "Production deployment isn’t allowed" + suffix, DiffConflict},
{"equivalent apostrophes", "Production deployment isn't allowed" + suffix, "Production deployment isn’t allowed" + suffix, DiffDuplicate},
{"unicode word boundary", "Production deployment is allowed" + suffix, "Production deployment is allowed with Noté" + suffix, DiffDuplicate},
{"noteworthy is not a marker", "Production deployment is allowed" + suffix, "Production deployment is noteworthy and allowed" + suffix, DiffDuplicate},
{"nonetheless is not a marker", "Production deployment is allowed" + suffix, "Production deployment is nonetheless allowed" + suffix, DiffDuplicate},
{"chinese intensifier", "生产部署状态稳定" + chineseSuffix, "生产部署状态非常稳定" + chineseSuffix, DiffDuplicate},
{"chinese future word", "生产部署计划已经确认" + chineseSuffix, "未来生产部署计划已经确认" + chineseSuffix, DiffDuplicate},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if similarity := JaccardSimilarity(tt.newText, tt.existing); similarity <= 0.9 {
t.Fatalf("fixture must reach the near-duplicate branch, got %f", similarity)
}
result := Diff([]*model.Insight{{ID: "existing", Content: tt.existing}}, tt.newText, DiffOptions{})
if result.Suggestion != tt.want {
t.Fatalf("suggestion = %s, want %s", result.Suggestion, tt.want)
}
})
}
}

func TestDiff_NegationInEmbeddingDuplicate(t *testing.T) {
result := Diff(
[]*model.Insight{{ID: "existing", Content: "Production deployment is allowed"}},
"Production rollout is not allowed",
DiffOptions{
NewEmbedding: []float64{1, 0},
ExistingEmbed: []EmbeddedItem{{ID: "existing", Embedding: []float64{0.95, 0.3122498999199199}}},
},
)
if len(result.Matches) != 1 || result.Matches[0].TokenSimilarity > 0.9 || result.Matches[0].Similarity <= 0.9 {
t.Fatalf("fixture must reach the embedding near-duplicate branch: %+v", result)
}
if result.Suggestion != DiffConflict {
t.Fatalf("suggestion = %s, want CONFLICT", result.Suggestion)
}
}
Loading