유대선
프로젝트로
·기술 회고·12

Lookalikes ≠ Contradictions: Memory Conflict Detection from Cosine to an LLM Judge (a full day, in detail)

How do you stop the 'operating memory' that AI agents read from contradicting itself? Started with cosine similarity, realized on real data that it can't actually judge contradictions, and moved to a claude -p 'LLM judge.' Four layers of defense, when each one fires, and the honest conclusion that 'there's no mechanism that drives contradictions to exactly zero.'

This post is written to be "understandable from scratch even when you come back to it later." Concept basics → what I built → key insight → limits → next. If you're busy, just read the ⭐ MOST IMPORTANT block right below.


⭐ MOST IMPORTANT (these 4 are all you need to remember)

🔑 1. "Cosine similarity" only finds lookalikes; it can't judge contradictions. "가격은 월 20달러" (the price is $20/month) vs "가격은 월 40달러" (the price is $40/month) are 92% similar in text (cosine 0.92), so the system flagged them as "conflict!" But these could be the prices of two different products. Cosine only knows "this sounds similar"; it doesn't know "these make mutually exclusive claims about the same thing."

🔑 2. So the real verdict is made by an LLM (cosine = cheap pre-filter, LLM = the actual judge). I call claude, let it read the two records, and ask "is this a real contradiction about the same subject?" For the 20/40 case above, claude answered "상품명이 없어 같은 대상의 가격 변동인지 다른 상품의 가격인지 판단 불가능" (no product name, so it's impossible to tell whether this is a price change for the same subject or the price of a different product) — honestly filtering out cosine's false positive.

🔑 3. No method makes "contradictions = 0." It only reduces them. Detection is incomplete (it misses anything worded differently), there's no guarantee the human/agent resolves it correctly, and "contradiction" is an endless semantic problem. So the goal isn't "zero distortion" — it's memory that is grounded · labeled · cited · refused when there's no grounding · and correctable.

🔑 4. The defense is 4 layers, not 1 (in order, earliest first).at write time (write-time, prevents accumulation) → ② at answer time (read-reactive, insurance) → ③ human review (manual panel) → and ④ an LLM judge filters the candidates. When one layer misses, the next one catches it.


1. Why I did this — start from the problem

Dalkkak's core is "AI does the work, you stay in control," and for that, the AI agent has to keep carrying past context (operating memory) forward. So we:

The problem is — when memory goes wrong (gets distorted), the agent confidently gives a wrong answer. That's the scenario that kills the product. Example: if memory holds both "price $20" (old, a reversed decision) and "price $29" (current), the agent quietly picks $20 and asserts "the price is $20."

These contradictions inevitably pile up over time — decisions get reversed (Fly→Vercel migration), terminology changes (Project→Service), plans get put on hold. That's why we needed "conflict detection."


2. Concept basics (you need these for the rest to make sense)

Embedding: turning a sentence into a number vector (here, 384 dimensions). Sentences with similar meaning become similar vectors. We use a local model with no network (MiniLM), so it runs free and instantly.

Cosine similarity (cosine): a 0–1 measure of how much two vectors point in the same direction. The closer to 1, the more "the text is similar." The trap here: this is "topic/wording is similar," not "the facts contradict." (That's today's core lesson.)

Clustering (union-find): grouping similar things together. "A is similar to B, B is similar to C" → A·B·C in one group. Lower the threshold and it groups more loosely (more); raise it and it groups more tightly (fewer).

supersede (marking-as-stale): instead of deleting old memory, attach a "stale" edge so it drops out of future searches/answers. Reversible (the original stays as-is). This is our correction mechanism.

provenance (origin/trust): where each memory came from — git (commits), user (a human), agent (recorded by an agent), external connectors (gmail, etc.). External is not trusted (injection defense).


3. What I built — 4 layers of contradiction defense

The key is when each layer fires.

③ Manual panel (the first thing built, memory_conflicts)

When a human opens it with ⌘⇧C, it cosine-clusters the active service's memory and shows "groups saying almost the same thing." The human picks "the current one" and the rest get superseded.

🐛 Bug the first dogfood caught: when I opened it on real data, the groups were all ads/notification emails imported from Gmail. The conflict detection wasn't operating on operating memory — it was deduping the inbox. Cause: emails came in as node_type: issue and the filter wasn't excluding external nodes. Also the same node_id was duplicated in the append log, so the same email showed up twice in one group. Fix (46ff1cd): exclude is_external_untrusted nodes (gmail, etc.) from candidates + dedup node_id. → scans only real operating memory.

② read-reactive (reactive_conflict_note)

When an agent (or the in-app librarian) builds an answer from memory, if the pulled-in memories conflict with each other, it injects a ⚠️ warning into the answer prompt: "these two don't agree, don't assert — disclose it."

① write-time (resolve_write_conflict)

The moment a memory gets recorded, compare against existing ones:

🔧 Side lesson: when I tried to add the conflicts_with edge, the secret-guard (the secret-detection hook) blocked it. Turned out the guard was false-flagging its own secret-detection code (the token_len = ... variable, whose name contains 'token') as a secret. The --no-verify bypass is blocked by the auto-classifier (a correct control), so I handled that line with the guard's official allowlist marker (dalkkak-secret-guard: allow). Don't disable the security control — use the official exception mechanism that control provides.

④ LLM judge (memory_judge_cluster) ← today's highlight

①②③ above all find conflicts with cosine. But cosine can't judge contradictions (see §4). So once candidates are caught, hand the two records to claude -p (BYO), let it read them, and make it do the real judgment.


4. The key insight — "cosine can't judge contradictions" (demonstrated)

This is the most important thing I learned today. I verified it directly with a test.

Test: I put two nodes into Memory Lab: 가격은 월 20달러 (the price is $20/month) and 가격은 월 40달러 (the price is $40/month).

Asked the LLM judge the same thing:

🟡 Can't decide — subject unknown · different_subject "상품명이 없어 같은 대상의 가격 변동인지 다른 상품의 가격인지 판단 불가능" (no product name, so it's impossible to tell whether this is a price change for the same subject or the price of a different product)

→ claude said "I can't decide whether this is a real contradiction" and honestly filtered out cosine's false positive. That's the whole point of the LLM judge:

cosine (cheap · instant · dumb)  = 1000 → narrow to a few candidates

LLM judge (slow · smart)         = "same subject, real contradiction?" → only the real ones pass, 'unknown' if it can't tell

(Example of a real contradiction: "백엔드는 Fly에 배포" (deploy the backend on Fly) vs "Fly 접고 Vercel로 이전" (drop Fly and migrate to Vercel) → LLM: same subject · reversal · latest=Vercel is current.)


5. Honest limits (the LLM judge isn't a silver bullet either)

Conclusion: no machine eliminates contradictions to zero. It only greatly reduces them. So the real line of defense isn't conflict detection — it's grounding + citation + refusing with "no grounding" when there's no grounding + an external=untrusted fence + supersede (correction). That's the body; the 4 layers of conflict detection are reinforcement on top.


6. What I didn't do (on purpose) + next

I went through the 6 remaining considerations, and most of them only hurt "once real contradictions have piled up." Right now the memory is clean (0 conflicts at the high threshold). So I deliberately didn't build them:

And "ask on the spot when an agent writes a contradiction" (tentatively C-write): right now the agent unilaterally emits a <dk-node> block and a background worker (poll_once) picks it up later. So there's no synchronous point to ask at the moment of writing. To actually do that, you'd have to restructure so the agent writes via an MCP tool and the call returns the conflict immediately (a big change). This is on hold too — it doesn't definitively eliminate contradictions, and that problem hasn't shown up yet.

➡️ Next = not code, but dogfood. I'll actually use it for a few days and watch whether "a case where the agent answered wrong because of old memory" comes up even once. If it does → that's evidence the specific A–F / C-write item that case points to is needed. If it doesn't → what we have now is enough.


7. Meta lessons (what I learned in the process)


Commit map for this session