Skip to content

fix(store): persist not_conflict verdicts in mem_compare and ScanProject#491

Open
goutham80808 wants to merge 5 commits into
Gentleman-Programming:mainfrom
goutham80808:fix/mem-compare-not-conflict-persist
Open

fix(store): persist not_conflict verdicts in mem_compare and ScanProject#491
goutham80808 wants to merge 5 commits into
Gentleman-Programming:mainfrom
goutham80808:fix/mem-compare-not-conflict-persist

Conversation

@goutham80808

@goutham80808 goutham80808 commented Jun 13, 2026

Copy link
Copy Markdown

Fixes #490.

Summary

JudgeBySemantic returned success without inserting a row for not_conflict, causing FindCandidates to re-surface the same pair on every session. This PR makes not_conflict verdicts persist end-to-end and plugs the dedup gap in FindCandidates.

Changes

Core fix (commit 1 — 050f288):

  • Removed no-op early return in JudgeBySemanticnot_conflict now persists via existing UPSERT
  • Removed ScanProject skip that prevented not_conflict from being persisted
  • Added ExcludeNotConflict to ListRelationsOptions; dashboard and CLI conflict views set it
  • GetRelationStats excludes not_conflict (it's a conflict stats view)

Reviewer feedback (commit 2 — 2a21160, rebased onto main):

  • FindCandidates now skips pairs that already have a relation row in either direction — closes the user-visible symptom from fix(store): mem_compare not_conflict returns success but persists nothing #490 (every save re-inserted a fresh pending row for the same pair). Same existence check as ScanProject's Phase 3 pre-check.
  • Removed dead SemanticSkipped field from ScanResult, CLI output, server JSON, and all tests (the skip that incremented it was removed in commit 1, leaving the field permanently zero).
  • Removed dead pairResult struct (defined but never instantiated).
  • Hardened tests per bot review: TestHandleCompare_NotConflict_Persists asserts the stored relation row; TestScanProject_Semantic_NotConflictPersisted captures the count-query error instead of ignoring it.
  • Added TestFindCandidates_SkipsExistingRelation — two calls with the same pair; second must return 0 candidates.
File Change
internal/store/relations.go JudgeBySemantic persists not_conflict; FindCandidates dedup check; removed SemanticSkipped + dead pairResult; ExcludeNotConflict in ListRelationsOptions; GetRelationStats filter
internal/store/relations_test.go Added TestFindCandidates_SkipsExistingRelation
internal/store/scan_semantic_test.go Renamed/updated for persistence; dropped SemanticSkipped from sums; assert count-query error
internal/store/judge_by_semantic_test.go not_conflict persistence assertions
internal/mcp/mcp.go Tool description updated
internal/mcp/mcp_compare_test.go Assert stored relation row for not_conflict
internal/server/server.go handleListConflicts + handleScanConflicts filter; removed semantic_skipped from JSON
internal/server/server_test.go Dropped semantic_skipped from field checks
cmd/engram/conflicts.go cmdConflictsList filter; removed semantic_skipped from CLI output

Design decisions

  1. FindCandidates filter is a query-level check, not a schema UNIQUE. memory_relations is sync-replicated and direction-sensitive; a DB constraint on (source_id, target_id) would reject legitimate re-judgments via JudgeBySemantic's UPSERT. Same approach as the existing ScanProject pre-check.

  2. SemanticSkipped removed, not repurposed. No production code increments it after commit 1. An always-zero JSON field is worse than none.

  3. Sync payload includes not_conflict rows. Syncing the verdict prevents other nodes from re-evaluating the same pair and wasting LLM calls.

Test plan

  • Unit tests pass: go test ./... (22 packages, 0 failures)
  • E2E tests pass: go test -tags e2e ./internal/server/...
  • go build ./... + go vet ./... clean
  • Manually verified TestFindCandidates_SkipsExistingRelation fails without the dedup check

Summary by CodeRabbit

  • Bug Fixes
    • /conflicts now consistently returns only conflict relations and reports a matching total.
    • Semantic conflict scan responses no longer include semantic_skipped; semantic counters are aligned to judged/error metrics.
  • New Features
    • not_conflict verdicts are now persisted and reused during semantic evaluation and scanning.
    • Candidate discovery avoids re-surfacing already-evaluated/pending source/target pairs, with tighter in-scan deduping and bidirectional checks.
  • Tests
    • Updated/added coverage for not_conflict persistence, conflict-only listing, and candidate skipping behavior.

Copilot AI review requested due to automatic review settings June 13, 2026 13:24
@coderabbitai

coderabbitai Bot commented Jun 13, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR persists not_conflict relation verdicts, prevents already evaluated pairs from resurfacing, excludes non-conflict rows from conflict views, and removes the semantic_skipped counter from scan results and output.

Changes

not_conflict persistence and conflict-view alignment

Layer / File(s) Summary
Relation contracts and persistence flow
internal/store/relations.go, internal/mcp/*, internal/store/*_test.go
JudgeBySemantic and semantic workers persist not_conflict verdicts through relation upserts, returning a sync_id; MCP contracts and tests reflect this behavior.
Candidate and conflict-view filtering
internal/store/relations.go, internal/server/server.go, cmd/engram/conflicts.go, internal/store/relations_test.go, internal/server/server_test.go
Existing relation pairs are skipped, while conflict listings, totals, statistics, and CLI output exclude not_conflict rows.
Semantic counters and validation
internal/store/scan_semantic_test.go, internal/server/server_test.go
Semantic scan and HTTP tests use semantic_judged and semantic_errors without semantic_skipped, and verify persisted non-conflict verdicts.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant mem_compare
  participant JudgeBySemantic
  participant memory_relations
  Client->>mem_compare: submit not_conflict verdict
  mem_compare->>JudgeBySemantic: evaluate relation
  JudgeBySemantic->>memory_relations: upsert relation row
  memory_relations-->>mem_compare: return sync_id
  mem_compare-->>Client: return persisted sync_id
Loading

Possibly related PRs

Suggested reviewers: copilot, gentleman-programming, alan-thegentleman

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly states the main change: persisting not_conflict verdicts in mem_compare and ScanProject.
Linked Issues check ✅ Passed The changes persist not_conflict verdicts, update docs/tests, and prevent already-evaluated pairs from resurfacing, matching #490.
Out of Scope Changes check ✅ Passed No clear unrelated code changes stand out; the added filtering, persistence, and test updates all support the stated goals.
Docstring Coverage ✅ Passed Docstring coverage is 91.67% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

This PR changes semantic scanning behavior so "not_conflict" verdicts are persisted (instead of treated as a no-op), and updates conflict-focused views to exclude "not_conflict" rows.

Changes:

  • Persist "not_conflict" via JudgeBySemantic and ScanProject, and update related tests/docs.
  • Add ExcludeNotConflict to ListRelationsOptions and use it in server/CLI conflict listings.
  • Exclude "not_conflict" from relation stats queries to keep stats conflict-focused.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
internal/store/scan_semantic_test.go Updates semantic scan test to expect persisted not_conflict rows.
internal/store/relations.go Persists not_conflict, adds ExcludeNotConflict, updates stats/query behavior and comments.
internal/store/judge_by_semantic_test.go Updates semantic judge tests to assert not_conflict persistence.
internal/server/server.go Ensures conflicts endpoint excludes not_conflict rows.
internal/mcp/mcp_compare_test.go Updates MCP compare test to expect a persisted not_conflict sync_id.
internal/mcp/mcp.go Updates MCP tool docs/output expectations for not_conflict persistence.
cmd/engram/conflicts.go Ensures CLI conflicts list excludes not_conflict rows.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/store/scan_semantic_test.go Outdated
Comment on lines 183 to 187
var count int
_ = s.db.QueryRow(
`SELECT count(*) FROM memory_relations WHERE marked_by_actor = 'engram'`,
`SELECT count(*) FROM memory_relations
WHERE marked_by_actor = 'engram' AND relation = 'not_conflict'`,
).Scan(&count)
Comment on lines +1062 to +1064
if opts.ExcludeNotConflict {
query += ` AND r.relation != 'not_conflict'`
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d8019a9 — both buildRelationsQuery and GetRelationStats now use ? with args = append(args, RelationNotConflict). Removes the drift risk you flagged.

Comment on lines 1097 to 1119
@@ -1099,14 +1104,16 @@ func (s *Store) GetRelationStats(project string) (RelationStats, error) {
FROM memory_relations r
LEFT JOIN observations src ON src.sync_id = r.source_id AND src.deleted_at IS NULL
LEFT JOIN observations tgt ON tgt.sync_id = r.target_id AND tgt.deleted_at IS NULL
WHERE ifnull(src.project,'') = ? OR ifnull(tgt.project,'') = ?
WHERE (ifnull(src.project,'') = ? OR ifnull(tgt.project,'') = ?)
AND r.relation != 'not_conflict'
GROUP BY r.relation, r.judgment_status
`
args = []any{project, project}
} else {
q = `
SELECT relation, judgment_status, count(*) AS cnt
FROM memory_relations
WHERE relation != 'not_conflict'
GROUP BY relation, judgment_status
`
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partial address in d8019a9. Renaming the function or splitting into GetConflictStats vs GetRelationStatsAll would be a breaking API change across CLI (cmd/engram/conflicts.go:262) and server (server.go:1053) — out of scope for this bugfix PR.

Updated the doc comment to state explicitly that not_conflict is excluded by design and that callers wanting inclusive enumeration should use ListRelations. Both current callers are conflict-focused (conflicts dashboard + CLI conflicts list), so no consumer needs inclusive stats today. A proper rename can ship in a dedicated refactor PR if/needed.

@goutham80808

goutham80808 commented Jun 13, 2026

Copy link
Copy Markdown
Author

hey @Alan-TheGentleman , i've made the changes. and below are the design decisions i've took. Would you please take a look on this.

Design Decisions — Reviewer Please Confirm

During implementation, 4 design decisions emerged that I want to flag for reviewer confirmation. All are implemented in this PR.


1. ScanProject now persists not_conflict (was previously skipped)

Decision: Removed the short-circuit at the old relations.go:1399 that skipped not_conflict verdicts in ScanProject's semantic goroutine.

Why: ScanProject Phase 3 creates pending rows. Phase 4's semantic goroutine gets the verdict from the runner. Previously, not_conflict was skipped — leaving the row as pending forever. On subsequent scans, the pre-check sees the pending row and counts it as AlreadyRelated, but the dashboard shows a never-resolving pending relation. Now not_conflict flows through to JudgeBySemantic, which updates the row to judged/not_conflict — same as all other verdicts.

Impact: SemanticSkipped will no longer increment for not_conflict verdicts from automated scans. These now count as SemanticJudged. The old TestScanProject_Semantic_NotConflictSkipped test is renamed to TestScanProject_Semantic_NotConflictPersisted and asserts the new behavior.


2. Dashboard / CLI filter not_conflict from conflict views

Decision: Added ExcludeNotConflict bool to ListRelationsOptions. buildRelationsQuery filters relation != 'not_conflict' when set. GetRelationStats unconditionally excludes not_conflict (it's a conflict stats view). All 4 conflict-view callers set the flag.

Why: Without filtering, not_conflict rows would appear in the conflicts dashboard and engram conflicts list — semantically confusing since these aren't conflicts.

Scope note: This touches server.go and conflicts.go, beyond the core bugfix. If a different approach is preferred (separate endpoint, UI toggle, handler-level filtering), these files are easy to revert independently.


3. FindCandidates left unchanged (no relation filtering)

Decision: FindCandidates remains a pure FTS5 query. It does NOT check memory_relations for existing relation rows.

Why: The fix works indirectly — when JudgeBySemantic persists not_conflict, the UPSERT resolves any existing pending row to judged/not_conflict, which removes the "contested by #N (pending)" annotation from search results. FindCandidates itself still surfaces the pair as a candidate on the next mem_save, but that's acceptable — it's a search function, not a relation filter. The issue listed this as optional.


4. Sync payload includes not_conflict rows (no exclusion)

Decision: ExportRelationMutations exports all non-orphaned relation rows, including not_conflict. No change made.

Why: Syncing the not_conflict verdict prevents other nodes from re-evaluating the same pair. If excluded from sync, a synced node would receive a pending relation, evaluate it independently, and potentially waste LLM API calls duplicating work already done elsewhere.


All feedback welcome. If any decision needs a different approach, I'll adjust before merge.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/server/server.go (1)

981-1011: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Add endpoint tests for the new /conflicts filtering behavior.

Line 981 through Line 1011 changes endpoint semantics (ExcludeNotConflict on both list and total), but no matching handler/route response test updates are included in this PR slice. Please add tests that verify parse/validation and that both relations and total exclude not_conflict.

As per coding guidelines, “internal/server/**/*.go: New or modified endpoints need handler tests (parsing/validation) and route+response tests.” and “**/*_test.go: Behavior changes without tests should be blocked.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/server/server.go` around lines 981 - 1011, This change adds
ExcludeNotConflict to both the list call and the count call but no tests were
updated; add handler and route/response tests that exercise the new /conflicts
filtering: update or create tests that call the HTTP handler that builds
ListRelationsOptions (verify parsing/validation of the since query parameter
using time.RFC3339 and that invalid values return 400), and add assertions that
s.store.ListRelations is invoked (or the HTTP response contains) only relations
excluding status "not_conflict" and that the returned JSON "total" field is the
count computed with the same ExcludeNotConflict behavior (mirror
countOpts.SinceTime usage). Target the code paths that construct opts and
countOpts and the call sites s.store.ListRelations and ensure both relations and
total exclude not_conflict.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/mcp/mcp_compare_test.go`:
- Around line 118-122: The test currently only asserts syncID is non-empty;
after the existing syncID check add a lookup of the persisted relation row for
that sync ID and assert its relation field equals "not_conflict". Concretely,
immediately after the syncID, use the test's DB/test helper (or perform a direct
query) to fetch the persisted row by sync_id (e.g., call a helper like
getRelationBySyncID(syncID) or DB.QueryRow("SELECT relation FROM relations WHERE
sync_id = ?", syncID).Scan(&relation)) and then t.Fatalf/t.Errorf if relation !=
"not_conflict". Ensure this lookup runs in the same test context and uses
existing DB/test fixtures so the assertion validates persistence of the
"not_conflict" relation.

---

Outside diff comments:
In `@internal/server/server.go`:
- Around line 981-1011: This change adds ExcludeNotConflict to both the list
call and the count call but no tests were updated; add handler and
route/response tests that exercise the new /conflicts filtering: update or
create tests that call the HTTP handler that builds ListRelationsOptions (verify
parsing/validation of the since query parameter using time.RFC3339 and that
invalid values return 400), and add assertions that s.store.ListRelations is
invoked (or the HTTP response contains) only relations excluding status
"not_conflict" and that the returned JSON "total" field is the count computed
with the same ExcludeNotConflict behavior (mirror countOpts.SinceTime usage).
Target the code paths that construct opts and countOpts and the call sites
s.store.ListRelations and ensure both relations and total exclude not_conflict.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 3c9de56b-c61d-469f-a63b-b1779c6af634

📥 Commits

Reviewing files that changed from the base of the PR and between d38bbab and bb002b0.

📒 Files selected for processing (7)
  • cmd/engram/conflicts.go
  • internal/mcp/mcp.go
  • internal/mcp/mcp_compare_test.go
  • internal/server/server.go
  • internal/store/judge_by_semantic_test.go
  • internal/store/relations.go
  • internal/store/scan_semantic_test.go

Comment thread internal/mcp/mcp_compare_test.go
@github-actions

Copy link
Copy Markdown

This PR has been inactive for 30 days. Please rebase, update it, or leave a comment — otherwise it will be closed in 14 days.

@Alan-TheGentleman Alan-TheGentleman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this. The insert path is right and I verified the annotation builder only renders supersedes/conflicts_with/pending, so no not_conflict clutter reaches the agent.

But this only closes half of #490. The symptom actually reported there — "FindCandidates re-surfaces the same pair every session" — survives this change:

  • FindCandidates inserts a fresh pending row per candidate with no existence check, and memory_relations has no UNIQUE on (source_id, target_id) (only on sync_id). So the next mem_save re-inserts the pair and the agent re-evaluates it. Please add the existing-relation filter — without it the user-visible bug is still there.
  • Removing the ScanProject skip leaves SemanticSkipped never incremented in production code, so semantic_skipped is now permanently 0 in JSON output while the tests still sum it. Either remove the field or repurpose it — an always-zero field is worse than none.

type:bug applied. Repo CI hasn't run on this branch; a rebase should trigger it.

JudgeBySemantic returned success without inserting a row for not_conflict,
causing FindCandidates to re-surface the same pair on every session. Now all
verdicts (including not_conflict) are persisted via the existing UPSERT logic.

- Remove no-op early return in JudgeBySemantic for not_conflict
- Remove ScanProject skip that prevented not_conflict from being persisted
- Add ExcludeNotConflict filter to ListRelationsOptions for conflict views
- Filter not_conflict from GetRelationStats (conflict stats view)
- Set ExcludeNotConflict in dashboard and CLI conflict list handlers
- Update tests to assert not_conflict rows are created
- Update tool description and doc comments

Closes Gentleman-Programming#490
Address maintainer review feedback on Gentleman-Programming#491:

1. FindCandidates now skips pairs that already have a relation row in
   memory_relations (either direction). Without this check, every save
   re-inserted a fresh pending row for the same pair, re-surfacing it
   for agent re-evaluation -- the core user-visible bug from Gentleman-Programming#490.

2. Removed SemanticSkipped field from ScanResult, CLI output, server
   JSON, and all tests. The ScanProject skip that incremented it was
   removed in the previous commit, leaving the field permanently zero
   in production. An always-zero field is worse than none.

3. Removed dead pairResult struct (was defined but never used).

4. Hardened tests per review feedback:
   - TestHandleCompare_NotConflict_Persists: assert stored relation row
   - TestScanProject_Semantic_NotConflictPersisted: assert Scan error
   - Added TestFindCandidates_SkipsExistingRelation
Copilot AI review requested due to automatic review settings July 20, 2026 15:55
@goutham80808
goutham80808 force-pushed the fix/mem-compare-not-conflict-persist branch from bb002b0 to 2a21160 Compare July 20, 2026 15:55
@goutham80808

Copy link
Copy Markdown
Author

Thanks for the thorough review @Alan-TheGentleman. Both items are addressed in 2a21160, rebased onto main (no conflicts — CI should trigger now).

1. FindCandidates existing-relation filter ✓

Added a pre-check before each INSERT that mirrors the one in ScanProject's Phase 3 path (relations.go:1330):

var exists int
if err := s.db.QueryRow(
    `SELECT 1 FROM memory_relations
     WHERE (source_id = ? AND target_id = ?)
        OR (source_id = ? AND target_id = ?)
     LIMIT 1`,
    sourceSyncID, rc.syncID, rc.syncID, sourceSyncID,
).Scan(&exists); err == nil {
    continue  // pair already has a relation row
}

If a row exists in either direction, the candidate is skipped entirely — no new pending row, no re-surfacing. This closes the user-visible symptom from #490: a not_conflict (or any other) verdict now sticks, and the next mem_save won't re-insert the pair.

Added TestFindCandidates_SkipsExistingRelation — two calls to FindCandidates with the same pair; the second must return 0 candidates.

2. Removed SemanticSkipped field ✓

Went with removal (your "worse than none" read). Touched:

  • ScanResult struct — field deleted
  • cmd/engram/conflicts.go — line removed from CLI output
  • internal/server/server.go — key removed from JSON response
  • internal/store/scan_semantic_test.go — sums updated to SemanticJudged + SemanticErrors
  • internal/server/server_test.go — field lists trimmed to ["semantic_judged", "semantic_errors"]
  • Also removed the dead pairResult struct (defined but never instantiated — workers mutate result.* directly under the mutex).

3. Bot review comments (drive-by)

While in the area, addressed the still-valid CodeRabbit/Copilot comments:

  • mcp_compare_test.goTestHandleCompare_NotConflict_Persists now looks up the row via GetRelation(syncID) and asserts relation == not_conflict.
  • scan_semantic_test.go:184 — the ignored QueryRow().Scan(&count) error is now captured and fails the test on query error.

Verification

  • go build ./... + go vet ./... clean
  • go test ./... — all 22 packages pass (no regressions vs. the pre-existing suite state)
  • go test -tags e2e ./internal/server/... — pass
  • Targeted: store 37, mcp 8, server 5, cmd conflicts all green

The FindCandidates filter is intentionally the same existence check as ScanProject's pre-check rather than a schema-level UNIQUE(source_id, target_id) — the table is sync-replicated and direction-sensitive, so a DB constraint would reject legitimate re-judgments via JudgeBySemantic's UPSERT path. Happy to revisit if you'd prefer the constraint instead.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 4 comments.

Comment thread internal/server/server.go
Comment on lines 1219 to 1222
// Semantic counters — always present; zero when semantic=false.
"semantic_judged": result.SemanticJudged,
"semantic_skipped": result.SemanticSkipped,
"semantic_errors": result.SemanticErrors,
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushing back here. Maintainer @Alan-TheGentleman explicitly requested removal in his review:

Removing the ScanProject skip leaves SemanticSkipped never incremented in production code, so semantic_skipped is now permanently 0 in JSON output while the tests still sum it. Either remove the field or repurpose it — an always-zero field is worse than none.

The "deprecate with always-0" alternative was the path he rejected. I went with removal per his directive. Happy to revisit if you and Alan disagree, but AFAICT the breaker is intentional.

Comment on lines 155 to +158
// Semantic counters — populated only when ScanOptions.Semantic is true.
// Zero-value is safe for existing JSON consumers.
SemanticJudged int `json:"semantic_judged"`
SemanticSkipped int `json:"semantic_skipped"`
SemanticErrors int `json:"semantic_errors"`
SemanticJudged int `json:"semantic_judged"`
SemanticErrors int `json:"semantic_errors"`

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same answer as the sibling comment on server.go:1222 — removal was the maintainer's explicit ask (see quote there). Struct field, server JSON key, CLI output line, and all test sums updated together in one pass.

Comment on lines +1539 to +1540
// Both semantic counters must be present and zero.
for _, field := range []string{"semantic_judged", "semantic_errors"} {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d8019a9 — comment now reads "the response includes semantic_judged=0 and semantic_errors=0", which matches the assertion loop on line 1540.

Comment on lines +1636 to +1637
// semantic_judged + semantic_errors should be present (values depend on FTS).
for _, field := range []string{"semantic_judged", "semantic_errors"} {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No change needed here — the comment on line 1636 already reads semantic_judged + semantic_errors should be present (values depend on FTS), which accurately describes the assertion loop immediately below it. The stale comment was on the sibling test SemanticFalse_CountersZero (still mentioned semantic_skipped=0); that one is fixed in d8019a9.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/store/relations.go (1)

1371-1376: 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Filter out already-evaluated pairs before queueing them for the semantic worker pool.

When Semantic=true, ScanProject passes SkipInsert=true to FindCandidates, which intentionally bypasses the DB existence check to avoid unwanted DB writes during candidate gathering. However, because the candidates are gathered blindly, the Semantic=true block must perform its own DB pre-check (just like the Semantic=false block does) to prevent wasting expensive LLM calls on already-judged pairs.

Additionally, because the worker pool evaluates pairs concurrently, you should track queued pairs in memory to avoid evaluating reciprocal pairs (e.g., A->B and B->A) within the same scan.

⚡ Proposed fix

Define a tracking map before the outer observations loop (around line 1339):

 	var semanticPairs []candidatePair
+	seenPairs := make(map[string]bool)

Then, apply this pre-check and dedup logic inside the candidate loop:

 			for _, c := range candidates {
+				// Avoid queuing reciprocal pairs (A->B and B->A) in the same run.
+				k1, k2 := obs.syncID, c.SyncID
+				if k1 > k2 {
+					k1, k2 = k2, k1
+				}
+				pairKey := k1 + "|" + k2
+				if seenPairs[pairKey] {
+					continue
+				}
+
+				// Pre-check: skip pairs that already have a relation row from previous runs.
+				var exists int
+				if err := s.db.QueryRow(
+					`SELECT 1 FROM memory_relations
+					 WHERE (source_id = ? AND target_id = ?)
+					    OR (source_id = ? AND target_id = ?)
+					 LIMIT 1`,
+					obs.syncID, c.SyncID, c.SyncID, obs.syncID,
+				).Scan(&exists); err == nil {
+					result.AlreadyRelated++
+					continue
+				} else if err != sql.ErrNoRows {
+					log.Printf("[store] ScanProject semantic pre-check obs=%s cand=%s: %v", obs.syncID, c.SyncID, err)
+					continue
+				}
+
 				if len(semanticPairs) >= maxSemantic {
 					// Cap reached — stop adding pairs.
 					result.Capped = true
 					break
 				}
+
+				seenPairs[pairKey] = true
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/store/relations.go` around lines 1371 - 1376, Update the
Semantic=true candidate-processing path in ScanProject around semanticPairs to
pre-check each candidate against the database before queueing it, matching the
Semantic=false behavior while preserving SkipInsert=true. Add an in-memory
seenPairs map before the observations loop and use a canonical pair key so
reciprocal candidates are skipped during the same scan; only unseen,
not-already-evaluated pairs should count toward maxSemantic and be queued for
the worker pool.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@internal/store/relations.go`:
- Around line 1371-1376: Update the Semantic=true candidate-processing path in
ScanProject around semanticPairs to pre-check each candidate against the
database before queueing it, matching the Semantic=false behavior while
preserving SkipInsert=true. Add an in-memory seenPairs map before the
observations loop and use a canonical pair key so reciprocal candidates are
skipped during the same scan; only unseen, not-already-evaluated pairs should
count toward maxSemantic and be queued for the worker pool.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a9eefbed-19ba-4dfc-8969-582734d06147

📥 Commits

Reviewing files that changed from the base of the PR and between bb002b0 and 2a21160.

📒 Files selected for processing (9)
  • cmd/engram/conflicts.go
  • internal/mcp/mcp.go
  • internal/mcp/mcp_compare_test.go
  • internal/server/server.go
  • internal/server/server_test.go
  • internal/store/judge_by_semantic_test.go
  • internal/store/relations.go
  • internal/store/relations_test.go
  • internal/store/scan_semantic_test.go

Address CodeRabbit/Copilot review feedback on Gentleman-Programming#491:

- ScanProject Semantic=true path now mirrors Phase 3's existence pre-check
  before queueing pairs for LLM evaluation. SkipInsert=true bypasses
  FindCandidates' dedup, so without this check pairs with existing relation
  rows would still be sent to the LLM (wasted API spend).
- Parameterize the 'not_conflict' SQL literal in buildRelationsQuery and
  GetRelationStats via the RelationNotConflict constant — removes drift
  risk if the constant value ever changes.
- GetRelationStats doc comment now states explicitly that not_conflict is
  excluded by design (conflicts-dashboard view; issue Gentleman-Programming#490).
- Add TestHandleListConflicts_ExcludesNotConflict: seeds a pending and a
  not_conflict pair in the same project; asserts both relations array and
  total exclude not_conflict.
- Add TestHandleListConflicts_InvalidSince_400: covers the RFC3339 parse
  /validation gap on the since query parameter.
- Fix stale doc comment on TestHandleScanConflicts_SemanticFalse_CountersZero
  that still referenced the removed semantic_skipped field.
Copilot AI review requested due to automatic review settings July 20, 2026 16:12
@goutham80808

Copy link
Copy Markdown
Author

@coderabbitai addressing your two outside-diff comments from review 4736643811 in d8019a9:

1. internal/store/relations.go:1371-1376 — pre-check + seenPairs map

DB pre-check: applied. The Semantic=true path uses SkipInsert=true, which short-circuits at relations.go:426 before the dedup check I added to FindCandidates at :457. So your concern was right — already-judged pairs were getting queued for LLM calls. Mirrored Phase 3's pre-check pattern (relations.go:1410-1424): same SQL, same result.AlreadyRelated++ counter, same error logging.

seenPairs reciprocal dedup: not applied — pushing back. The relation model is direction-sensitive: A→B and B→A can receive different verdicts from the LLM (e.g., "A supersedes B" vs. "B compatible with A"), and the Phase 3 path at relations.go:1404 deliberately inserts reciprocal pairs as distinct rows for the same reason. Adding dedup only in the semantic path would diverge from Phase 3 behavior and silently drop legitimate evaluations. If reciprocal dedup is desired, it should apply to both paths uniformly in a separate PR — not bolted onto this bugfix.

2. internal/server/server.go:981-1011 — endpoint tests for ExcludeNotConflict

Applied. Added two tests in internal/server/server_test.go:

  • TestHandleListConflicts_ExcludesNotConflict — seeds one pending pair and one not_conflict pair (via SaveRelation + JudgeRelation) in the same project; asserts both relations array length and total field reflect filtering.
  • TestHandleListConflicts_InvalidSince_400 — covers the RFC3339 parse/validation gap you flagged on the since query parameter; asserts 400 + non-empty error body.

Both pass locally along with the full 22-package suite.

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '== Targeted commit diff =='
git show --format=fuller --stat d8019a9 --
git show --format= --unified=40 d8019a9 -- internal/store/relations.go internal/server/server_test.go

echo '== Store file outline =='
ast-grep outline internal/store/relations.go --items all --type function,struct --match 'FindCandidates|ScanProject|buildRelationsQuery|ListRelationsOptions|ScanResult' --view expanded

echo '== Relevant relation-code contexts =='
rg -n -C 12 'SkipInsert|AlreadyRelated|seenPairs|SELECT 1 FROM memory_relations|func \(s \*Store\) (FindCandidates|ScanProject)' internal/store/relations.go

echo '== New server tests and handler context =='
ast-grep outline internal/server/server_test.go --items all --type function --match 'TestHandleListConflicts_(ExcludesNotConflict|InvalidSince_400)' --view expanded
rg -n -C 12 'TestHandleListConflicts_(ExcludesNotConflict|InvalidSince_400)|func \(s \*Server\) handleListConflicts|ExcludeNotConflict' internal/server/server.go internal/server/server_test.go

Length of output: 41081


@goutham80808 Confirmed the applied portions in d8019a9:

  • The semantic path now performs the existence check before adding a candidate to the LLM queue, increments AlreadyRelated, and handles unexpected query errors.
  • GET /conflicts applies ExcludeNotConflict consistently to both the result query and the total count.
  • The new endpoint tests cover both not_conflict exclusion and malformed since validation.

One correction on the reciprocal-dedup rationale: the current Phase 3 pre-check at internal/store/relations.go Lines 1433-1446 already treats a relation in either direction as existing. Therefore, after Phase 3 inserts A→B, it will skip B→A rather than deliberately insert it as a distinct directional relation.

So I agree that a semantic-only seenPairs change should not be added if directionality is intended, but the claimed Phase 3 invariant does not hold in the inspected code. Please either:

  1. retain the current unordered-pair behavior and adjust the rationale, or
  2. if A→B and B→A must genuinely be independent, update the existence checks uniformly in FindCandidates and both ScanProject paths, with regression coverage.

The pre-check and endpoint-test findings are addressed; the directional-pair explanation remains unresolved.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (2)

internal/server/server.go:1222

  • POST /conflicts/scan response no longer includes the previously documented semantic_skipped field. Repo docs/spec currently state that semantic_judged, semantic_skipped, and semantic_errors are always present (and describe not_conflict as skipped), so this is an API/contract change that should be reflected in documentation and any external consumers (or keep semantic_skipped as a deprecated always-0 field for backward compatibility).
	resp := map[string]any{
		"project":          result.Project,
		"inspected":        result.Inspected,
		"candidates_found": result.CandidatesFound,
		"already_related":  result.AlreadyRelated,
		"inserted":         result.RelationsInserted,
		"capped":           result.Capped,
		"dry_run":          result.DryRun,
		// Semantic counters — always present; zero when semantic=false.
		"semantic_judged":  result.SemanticJudged,
		"semantic_errors":  result.SemanticErrors,
	}

internal/store/relations.go:158

  • The ScanResult semantic-counter comment still implies JSON consumer compatibility, but this PR removes the semantic_skipped counter entirely and changes how not_conflict is handled. Consider either keeping a deprecated semantic_skipped field (always 0) for backwards compatibility, or update the surrounding documentation/specs to explicitly reflect the new counter set and persistence semantics.
	// Semantic counters — populated only when ScanOptions.Semantic is true.
	// Zero-value is safe for existing JSON consumers.
	SemanticJudged int `json:"semantic_judged"`
	SemanticErrors int `json:"semantic_errors"`

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/store/relations.go (1)

832-846: 🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

Update source_id and target_id during UPSERT to preserve relation direction.

When JudgeBySemantic updates an existing relation, it sets relation = ? but leaves source_id and target_id as they were in the database. Because the existence check queries both directions ((source, target) OR (target, source)), if an LLM or user judges that "A supersedes B" but the pending row was originally created as (B, A), the row is updated to relation = supersedes without swapping the IDs. This corrupts the data by persisting "B supersedes A".

To ensure directional relations (supersedes, scoped, etc.) maintain the correct polarity, overwrite source_id and target_id with the requested ones during the update.

🐛 Proposed fix
 			// Update existing row.
 			if _, execErr := tx.Exec(`
 				UPDATE memory_relations
 				SET relation        = ?,
+				    source_id       = ?,
+				    target_id       = ?,
 				    judgment_status = 'judged',
 				    confidence      = ?,
 				    reason          = ?,
 				    marked_by_actor = ?,
 				    marked_by_kind  = ?,
 				    marked_by_model = ?,
 				    updated_at      = datetime('now')
 				WHERE sync_id = ?
-			`, p.Relation, confidence, p.Reasoning,
+			`, p.Relation, p.SourceID, p.TargetID, confidence, p.Reasoning,
 				actor, kind, modelPtr,
 				existingSyncID,
 			); execErr != nil {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/store/relations.go` around lines 832 - 846, Update the
existing-relation UPDATE statement in JudgeBySemantic to also set source_id and
target_id from the requested relation endpoints before updating relation and
judgment fields. Keep the bidirectional existence check, but ensure the
persisted row uses the requested source/target polarity when an existing
relation is judged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/server/server_test.go`:
- Around line 1290-1292: Update the relation assertion in the test around the
existing len(relations) check to use t.Fatalf when the count is not exactly one,
then explicitly assert that relations[0].sync_id matches the expected pending
relation and thus confirms not_conflict was filtered out.

---

Outside diff comments:
In `@internal/store/relations.go`:
- Around line 832-846: Update the existing-relation UPDATE statement in
JudgeBySemantic to also set source_id and target_id from the requested relation
endpoints before updating relation and judgment fields. Keep the bidirectional
existence check, but ensure the persisted row uses the requested source/target
polarity when an existing relation is judged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: b10c4cd6-e73e-40ca-80a9-a8997dc372c4

📥 Commits

Reviewing files that changed from the base of the PR and between 2a21160 and d8019a9.

📒 Files selected for processing (2)
  • internal/server/server_test.go
  • internal/store/relations.go

Comment thread internal/server/server_test.go
…-scan reciprocal pairs

CodeRabbit's review on d8019a9 caught two real issues. Both fixed:

1. JudgeBySemantic UPSERT was not rewriting source_id/target_id when the
   bidirectional existence check matched a row in the reversed direction.
   Consequence: a directional verb like 'supersedes' would be persisted
   with the opposite polarity (B supersedes A stored when caller asked
   for A supersedes B, because the existing pending row was (B, A)).
   Fix: SET clause now includes source_id = ?, target_id = ? so the row
   reflects the caller's intended direction. Sync enqueue path reads
   the row after the update, so the replicated payload is consistent.

2. My earlier rationale for rejecting the seenPairs map was wrong. Phase 3
   deduplicates reciprocal pairs (A→B and B→A) via synchronous INSERT —
   when obs B's loop runs, the pre-check finds the row just inserted by
   obs A's loop. The Semantic path defers work to goroutines, so neither
   row exists when both candidates pass the pre-check, and both get
   queued for the worker pool — wasting one LLM call (the second UPSERT
   overwrites the first). Fix: track unordered pair keys in seenPairs
   and skip the second sighting within the same scan.

Also tightens TestHandleListConflicts_ExcludesNotConflict per CodeRabbit
nit (t.Fatalf on count, positive verification that the kept sync_id is
the pending one).

Tests:
- New TestJudgeBySemantic_UpsertRewritesDirection covers fix Gentleman-Programming#1 — seeds
  a (B, A, pending) row, calls JudgeBySemantic(A, B, supersedes), asserts
  source/target swap and single-row invariant.
- Existing TestScanProject_Semantic_HappyPath covers fix Gentleman-Programming#2 implicitly
  (asserts SemanticJudged > 0, still true with one pair instead of two).
- All 22 packages pass.
Copilot AI review requested due to automatic review settings July 20, 2026 16:32
@goutham80808

Copy link
Copy Markdown
Author

@coderabbitai you were right on both counts — pushing back was incorrect. Fixed in c97c617.

Re: Phase 3 reciprocal-pair rationale

You correctly pointed out that Phase 3's pre-check at relations.go:1428-1442 (now :1452-1466) checks both directions, so when obs B's loop runs after Phase 3 inserted A→B synchronously, the pre-check finds the row and skips. Phase 3 deduplicates reciprocal pairs naturally via synchronous execution — they are NOT deliberately inserted as distinct rows as I claimed.

The Semantic path queues work to goroutines, so neither row exists when both candidates pass the pre-check. Without an in-memory guard, both directions were getting queued and the second UPSERT was overwriting the first. Added the seenPairs map exactly as you suggested (canonical unordered pair key, marked after queueing, checked before the DB pre-check to save the query on duplicates).

Re: JudgeBySemantic direction corruption

This was the more important catch. The bidirectional existence check at relations.go:799-802 can match a (B, A) row when caller wants (A, B). The UPDATE I had was setting relation without touching source_id/target_id, so a directional verb like supersedes would persist with reversed polarity. Fix: SET clause now includes source_id = ?, target_id = ? so the row is rewritten to the caller's intended direction. Sync enqueue reads the row after the UPDATE, so the replicated payload is consistent.

Added TestJudgeBySemantic_UpsertRewritesDirection as regression coverage — seeds (B, A, pending) via SaveRelation, calls JudgeBySemantic(A, B, supersedes), asserts source/target swap + single-row invariant.

Both fixes verified with full 22-package suite. The other minor test nit (t.Fatalf on count + verify kept sync_id) is also applied.

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '== Commit summary and focused diff =='
git show --format=fuller --stat c97c617 --
git show --format= --unified=35 c97c617 -- internal/store/relations.go internal/store/judge_by_semantic_test.go internal/store/scan_semantic_test.go

echo '== Relevant declarations =='
ast-grep outline internal/store/relations.go --items all --type function,struct --match 'JudgeBySemantic|ScanProject|ScanResult' --view expanded
ast-grep outline internal/store/judge_by_semantic_test.go --items all --type function --match 'TestJudgeBySemantic_UpsertRewritesDirection' --view expanded

echo '== Focused implementation and test contexts =='
rg -n -C 18 'seenPairs|canonical|JudgeBySemantic|source_id = \?|target_id = \?|TestJudgeBySemantic_UpsertRewritesDirection' \
  internal/store/relations.go internal/store/judge_by_semantic_test.go internal/store/scan_semantic_test.go

Length of output: 91897


@goutham80808 Confirmed in c97c617:

  • seenPairs uses a canonical unordered key and is recorded only once a pair is actually queued, preventing reciprocal candidates from issuing duplicate semantic calls during the same scan.
  • JudgeBySemantic now rewrites source_id and target_id on the existing-row update, so directional verdicts retain the caller’s intended polarity.
  • TestJudgeBySemantic_UpsertRewritesDirection directly covers the reversed-pending-row case, including sync ID preservation and the single-row invariant.

The remaining finding is addressed. Nicely corrected. 🐇


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/store/relations.go (1)

1395-1439: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Fix indentation and optimize seenPairs marking.

The semanticPairs = append(...) block is indented by an extra tab.

Additionally, consider moving seenPairs[pairKey] = true to immediately after the seenPairs check. This prevents redundant database queries for reciprocal pairs that already exist in the database (since both A->B and B->A are emitted as candidates) and avoids double-counting result.AlreadyRelated within the same scan.

♻️ Proposed refactor
 			pairKey := k1 + "|" + k2
 			if seenPairs[pairKey] {
 				continue
 			}
+			seenPairs[pairKey] = true
 
 			// Pre-check: skip pairs that already have any relation row in either
 			// direction. SkipInsert=true above bypasses FindCandidates' built-in
 			// existence check, so we mirror the Phase 3 pre-check here to avoid
 			// wasting LLM calls on already-judged pairs from PREVIOUS scans.
 			var exists int
 			if err := s.db.QueryRow(
 				`SELECT 1 FROM memory_relations
 				 WHERE (source_id = ? AND target_id = ?)
 				    OR (source_id = ? AND target_id = ?)
 				 LIMIT 1`,
 				obs.syncID, c.SyncID, c.SyncID, obs.syncID,
 			).Scan(&exists); err == nil {
 				result.AlreadyRelated++
 				continue
 			} else if err != sql.ErrNoRows {
 				log.Printf("[store] ScanProject: semantic pre-check obs=%s cand=%s: %v", obs.syncID, c.SyncID, err)
 				continue
 			}
 
 			if len(semanticPairs) >= maxSemantic {
 				// Cap reached — stop adding pairs.
 				result.Capped = true
 				break
 			}
 			var candTitle, candType, candContent string
 			_ = s.db.QueryRow(
 				`SELECT title, type, ifnull(content,'') FROM observations WHERE sync_id = ?`, c.SyncID,
 			).Scan(&candTitle, &candType, &candContent)
-				semanticPairs = append(semanticPairs, candidatePair{
-					sourceSnippet: srcSnippet,
-					candidateSnippet: ObservationSnippet{
-						ID:      c.ID,
-						SyncID:  c.SyncID,
-						Title:   candTitle,
-						Type:    candType,
-						Content: candContent,
-					},
-				})
-				seenPairs[pairKey] = true
-			}
+			semanticPairs = append(semanticPairs, candidatePair{
+				sourceSnippet: srcSnippet,
+				candidateSnippet: ObservationSnippet{
+					ID:      c.ID,
+					SyncID:  c.SyncID,
+					Title:   candTitle,
+					Type:    candType,
+					Content: candContent,
+				},
+			})
+		}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/store/relations.go` around lines 1395 - 1439, Fix the extra
indentation in the semanticPairs append block within the ScanProject candidate
loop. Move seenPairs[pairKey] = true to immediately after the existing seenPairs
check, before the database pre-check, while preserving the continue behavior for
previously seen pairs and preventing reciprocal candidates from repeating
database checks or incrementing result.AlreadyRelated twice.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@internal/store/relations.go`:
- Around line 1395-1439: Fix the extra indentation in the semanticPairs append
block within the ScanProject candidate loop. Move seenPairs[pairKey] = true to
immediately after the existing seenPairs check, before the database pre-check,
while preserving the continue behavior for previously seen pairs and preventing
reciprocal candidates from repeating database checks or incrementing
result.AlreadyRelated twice.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 752eea86-681b-485f-9bbf-0d85be6f8545

📥 Commits

Reviewing files that changed from the base of the PR and between d8019a9 and c97c617.

📒 Files selected for processing (3)
  • internal/server/server_test.go
  • internal/store/judge_by_semantic_test.go
  • internal/store/relations.go

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

internal/store/relations.go:1432

  • This block is mis-indented (extra leading tab), which suggests the file wasn’t gofmt’d after the edits and reduces readability in a dense control-flow section. Reformat the append+seenPairs lines to match surrounding indentation (gofmt will do this automatically).
				semanticPairs = append(semanticPairs, candidatePair{
					sourceSnippet: srcSnippet,
					candidateSnippet: ObservationSnippet{
						ID:      c.ID,
						SyncID:  c.SyncID,

Comment on lines +458 to +462
if err := s.db.QueryRow(
`SELECT 1 FROM memory_relations
WHERE (source_id = ? AND target_id = ?)
OR (source_id = ? AND target_id = ?)
LIMIT 1`,

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both addressed in 7167e9a.

Indentation: confirmed — gofmt -l flagged relations.go. Applied gofmt -w to the whole file; the candidate loop is now at the correct nesting depth.

seenPairs reordering: applied exactly as you suggested. seenPairs[pairKey] = true now sits immediately after the if seenPairs[pairKey] { continue } check, before the DB pre-check. Saves one DB query when the first direction of an unordered pair fails the DB pre-check (existing relation row) — previously the reciprocal would have hit the DB again before being skipped. End behavior unchanged: each unordered pair is still processed at most once per scan.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry — my previous reply was for the sibling CodeRabbit indentation thread; let me address your actual N+1 question.

Pushing back on this one. Considered carefully, but I don't think the change is worth it here:

  1. Magnitude: engram ships with SQLite in-process. Each query is a B-tree lookup, ~10–100μs. With Limit=3 (default) the "N+1" is 3 extra round trips per save — single-digit microseconds. Not a real bottleneck.

  2. Consistency with Phase 3: ScanProject's Phase 3 path at relations.go:1452+ uses the same SELECT-then-INSERT pattern with the same justification. Changing only FindCandidates would create an inconsistency; changing both expands scope beyond what @Alan-TheGentleman asked for.

  3. Race window argument cuts both ways: The combined INSERT...SELECT...WHERE NOT EXISTS would close a theoretical TOCTOU race between SELECT and INSERT. But in this codebase the worst case is two pending rows in opposite directions (A→B and B→A as distinct sync_ids), and JudgeBySemantic's bidirectional UPSERT consolidates them on the next verdict. So the race is self-healing — not data corruption.

  4. SQL complexity cost: The combined form is harder to read, harder to log per-candidate outcomes, and harder to debug when something goes wrong. The current form mirrors Phase 3 exactly and is easier for the next maintainer to reason about.

If we ever migrate off SQLite (e.g., to PostgreSQL with real network round trips), this becomes worth revisiting — and at that point we'd want to refactor Phase 3 the same way. Happy to do that in a dedicated perf PR if/when it matters. For this bugfix I'd rather keep the dedup logic uniform across both paths.

CodeRabbit/Copilot flagged indentation on the semantic candidate loop
left over from c97c617. gofmt -w applied.

Also hoisted seenPairs[pairKey] = true to immediately after the seenPairs
check (CodeRabbit suggestion). Previously it was marked only after queue
append, so if a pair's first direction failed the DB pre-check (existing
row from previous scan), the reciprocal direction would still trigger
a redundant DB query. Marking on first encounter closes that small gap.
Behavior unchanged — each unordered pair is still processed at most once
per scan, just with one less DB query in the edge case.
Copilot AI review requested due to automatic review settings July 20, 2026 16:49

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
internal/store/relations.go (2)

451-471: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Bidirectional exists-check SQL is duplicated three times in this file.

The same SELECT 1 FROM memory_relations WHERE (source_id=? AND target_id=?) OR (source_id=? AND target_id=?) LIMIT 1 pre-check appears here, again in ScanProject's semantic pre-check (lines ~1407-1420), and again in ScanProject's Phase 3 apply loop (lines ~1460-1474). Extracting a single helper (e.g. relationExists(sourceSyncID, targetSyncID string) (bool, error)) would remove the triplication and ensure any future change to the dedup predicate (e.g. accounting for soft-deletes) only needs to be made once.

♻️ Proposed helper extraction
+func (s *Store) relationExists(sourceSyncID, targetSyncID string) (bool, error) {
+	var exists int
+	err := s.db.QueryRow(
+		`SELECT 1 FROM memory_relations
+		 WHERE (source_id = ? AND target_id = ?)
+		    OR (source_id = ? AND target_id = ?)
+		 LIMIT 1`,
+		sourceSyncID, targetSyncID, targetSyncID, sourceSyncID,
+	).Scan(&exists)
+	if err == nil {
+		return true, nil
+	}
+	if err == sql.ErrNoRows {
+		return false, nil
+	}
+	return false, err
+}

Then each of the three call sites becomes a two-line if exists, err := s.relationExists(a, b); err != nil { ... } else if exists { ... }.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/store/relations.go` around lines 451 - 471, Extract the duplicated
bidirectional existence query into a Store helper such as
relationExists(sourceSyncID, targetSyncID string) (bool, error), preserving its
current error behavior and predicate. Replace the checks in FindCandidates and
both ScanProject locations with calls to this helper, handling returned errors
and skipping existing relations as before.

451-471: 🗄️ Data Integrity & Integration | 🔵 Trivial

Exists-check-then-insert is not atomic; no DB constraint backs the dedup guard.

The comment here correctly notes memory_relations has no UNIQUE on (source_id, target_id), so this SELECT-then-write pattern only guards against duplicates created within the same sequential code path (which is fine for the in-scan cases handled by this PR), but not against a concurrent second scan, or a concurrent direct mem_compare call, racing between the check and the write for the same pair. Consider adding a SQLite expression-based unique index over a normalized column pair (e.g. min(source_id,target_id), max(source_id,target_id), supported since SQLite 3.9) so duplicates are rejected at the DB layer regardless of caller concurrency, rather than relying solely on this application-level check.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/store/relations.go` around lines 451 - 471, Add a SQLite unique
expression index for memory_relations that normalizes source_id and target_id
with min/max, enforcing one undirected relation per pair at the database layer.
Update the relation insertion flow around FindCandidates and its write path to
handle unique-constraint conflicts safely while preserving existing
duplicate-skipping behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@internal/store/relations.go`:
- Around line 451-471: Extract the duplicated bidirectional existence query into
a Store helper such as relationExists(sourceSyncID, targetSyncID string) (bool,
error), preserving its current error behavior and predicate. Replace the checks
in FindCandidates and both ScanProject locations with calls to this helper,
handling returned errors and skipping existing relations as before.
- Around line 451-471: Add a SQLite unique expression index for memory_relations
that normalizes source_id and target_id with min/max, enforcing one undirected
relation per pair at the database layer. Update the relation insertion flow
around FindCandidates and its write path to handle unique-constraint conflicts
safely while preserving existing duplicate-skipping behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 58ce14fa-13de-4c7e-b238-c94f4b704b7c

📥 Commits

Reviewing files that changed from the base of the PR and between c97c617 and 7167e9a.

📒 Files selected for processing (1)
  • internal/store/relations.go

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(store): mem_compare not_conflict returns success but persists nothing

3 participants