From eae0a0ccf6da7becf377a1ef270286f3dc6d9bca Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 20 Jun 2026 11:31:39 +0000 Subject: [PATCH] =?UTF-8?q?feat(memory):=20architect=20contradiction-check?= =?UTF-8?q?=20(B=C2=A74,=20display-only)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PossibleContradictionFlaggedEvent + RelatedDecision (registered + serialization test) - ArchitectContradictionChecker: L3 similarity retrieval over prior decisions, threshold-gated, fake-testable; non-blocking (surfaces candidates only) - live wiring left as documented TODO (needs an architect-decision emit hook + in-session decision embedding into L3) Co-Authored-By: Claude Opus 4.8 --- .../kotlin/com/correx/apps/server/Main.kt | 10 ++ .../memory/ArchitectContradictionChecker.kt | 70 ++++++++++++ .../ArchitectContradictionCheckerTest.kt | 107 ++++++++++++++++++ .../core/events/events/ContradictionEvents.kt | 33 ++++++ .../events/serialization/Serialization.kt | 2 + .../ContradictionEventSerializationTest.kt | 36 ++++++ 6 files changed, 258 insertions(+) create mode 100644 apps/server/src/main/kotlin/com/correx/apps/server/memory/ArchitectContradictionChecker.kt create mode 100644 apps/server/src/test/kotlin/com/correx/apps/server/memory/ArchitectContradictionCheckerTest.kt create mode 100644 core/events/src/main/kotlin/com/correx/core/events/events/ContradictionEvents.kt create mode 100644 core/events/src/test/kotlin/com/correx/core/events/serialization/ContradictionEventSerializationTest.kt diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index f7c06d79..4cbc18e5 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -380,6 +380,16 @@ fun main() { privilegedLocations = privilegedLocations, allowedWorkspaceRoots = allowedWorkspaceRoots, ) + // TODO(wiring): ArchitectContradictionChecker (BACKLOG §B-§4) is complete but not yet live. + // `embedder` + `l3MemoryStore` are in scope here, but two preconditions are missing: + // (1) no server-side hook exposes the architect's decision *text* with an event-emit path + // (architect is a workflow-defined role/stage, not a known stage id; decisions become + // DecisionRecords via the journal reducer from generic events), and + // (2) decisions are only embedded into L3 at session end (ProjectMemoryService.persist), + // under turnId "project:", so an in-session architect run has nothing of its + // own to retrieve. Wiring this would mean inventing an architect-stage subscriber + + // emit path — deliberately left out per scope. To go live: subscribe to the architect + // decision, call ArchitectContradictionChecker.check(...), and emit the non-null result. // Built from a config snapshot and reused by ConfigService's rebuild hook so toggling // project.enabled / personalization.* applies live to the next session. fun buildProjectMemory(cfg: CorrexConfig): com.correx.apps.server.memory.ProjectMemoryService? = diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/memory/ArchitectContradictionChecker.kt b/apps/server/src/main/kotlin/com/correx/apps/server/memory/ArchitectContradictionChecker.kt new file mode 100644 index 00000000..bafddd21 --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/memory/ArchitectContradictionChecker.kt @@ -0,0 +1,70 @@ +package com.correx.apps.server.memory + +import com.correx.core.events.events.PossibleContradictionFlaggedEvent +import com.correx.core.events.events.RelatedDecision +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.inference.Embedder +import com.correx.core.router.l3.L3MemoryStore +import com.correx.core.router.l3.L3Query + +/** + * Display-only architect contradiction surfacing (BACKLOG §B-§4). When the architect records a + * decision, this embeds the decision text, retrieves semantically-near PRIOR decisions/ADRs from + * L3, and — if any clear the similarity threshold — returns a [PossibleContradictionFlaggedEvent] + * for the operator to eyeball. v1 is purely informational: there is no LLM judge and the caller + * emits the flag without ever halting or failing the stage. + * + * Namespace convention: distilled decision-journal lines are persisted into L3 by + * [ProjectMemoryService] under `turnId = "project:"` (trailing-`:` delimiter). This is + * the only decision-bearing L3 namespace that exists today, so [decisionNamespacePrefix] defaults + * to `"project:"` — a `startsWith` prefix, matching the trailing-`:` delimiter convention used by + * [L3RepoKnowledgeRetriever]'s `"repomap::"` filter. Hits are also constrained to PRIOR + * sessions (`entry.sessionId != sessionId`) so the architect never flags its own in-flight run. + */ +class ArchitectContradictionChecker( + private val embedder: Embedder, + private val l3MemoryStore: L3MemoryStore, + private val k: Int = DEFAULT_K, + private val scoreThreshold: Double = DEFAULT_SCORE_THRESHOLD, + private val decisionNamespacePrefix: String = DEFAULT_DECISION_NAMESPACE_PREFIX, +) { + /** + * @return a [PossibleContradictionFlaggedEvent] listing related prior decisions, or null when + * none clear the threshold. The CALLER emits it (display-only) — this never halts. + */ + suspend fun check( + sessionId: SessionId, + stageId: StageId, + decisionText: String, + ): PossibleContradictionFlaggedEvent? { + if (decisionText.isBlank()) return null + val vector = embedder.embed(decisionText) + val related = l3MemoryStore.query(L3Query(vector = vector, k = k * RETRIEVAL_OVERSAMPLE_FACTOR)) + .filter { it.entry.turnId.startsWith(decisionNamespacePrefix) } + .filter { it.entry.sessionId != sessionId } + .filter { it.score >= scoreThreshold } + .take(k) + .map { + RelatedDecision( + summary = it.entry.text, + score = it.score.toDouble(), + source = it.entry.turnId, + ) + } + if (related.isEmpty()) return null + return PossibleContradictionFlaggedEvent( + sessionId = sessionId, + stageId = stageId, + decisionSummary = decisionText, + related = related, + ) + } + + companion object { + const val DEFAULT_K = 5 + const val DEFAULT_SCORE_THRESHOLD = 0.75 + const val DEFAULT_DECISION_NAMESPACE_PREFIX = "project:" + private const val RETRIEVAL_OVERSAMPLE_FACTOR = 4 + } +} diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/memory/ArchitectContradictionCheckerTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/memory/ArchitectContradictionCheckerTest.kt new file mode 100644 index 00000000..36188d50 --- /dev/null +++ b/apps/server/src/test/kotlin/com/correx/apps/server/memory/ArchitectContradictionCheckerTest.kt @@ -0,0 +1,107 @@ +package com.correx.apps.server.memory + +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.inference.Embedder +import com.correx.core.router.l3.L3Hit +import com.correx.core.router.l3.L3MemoryEntry +import com.correx.core.router.l3.L3MemoryStore +import com.correx.core.router.l3.L3Query +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.util.UUID + +private class ContradictionOnesEmbedder(override val dimension: Int = 8) : Embedder { + override suspend fun embed(text: String): FloatArray = FloatArray(dimension) { 1f } +} + +/** Returns canned hits regardless of the query vector, so scores can be controlled precisely. */ +private class CannedL3MemoryStore(private val hits: List) : L3MemoryStore { + override suspend fun store(entry: L3MemoryEntry) = Unit + override suspend fun query(query: L3Query): List = hits.sortedByDescending { it.score }.take(query.k) + override suspend fun existsByTurnIdPrefix(prefix: String): Boolean = hits.any { it.entry.turnId.startsWith(prefix) } + override suspend fun close() = Unit +} + +class ArchitectContradictionCheckerTest { + + private val newSession = SessionId("new-session") + private val priorSession = SessionId("prior-session") + private val stageId = StageId("architect") + + private fun hit(text: String, score: Float, turnId: String, sessionId: SessionId = priorSession) = + L3Hit( + entry = L3MemoryEntry( + id = UUID.randomUUID().toString(), + sessionId = sessionId, + turnId = turnId, + text = text, + vector = FloatArray(8) { 1f }, + timestampMs = 0L, + ), + score = score, + ) + + @Test + fun `flags a related prior decision above threshold`() = runBlocking { + val store = CannedL3MemoryStore( + listOf(hit("Decided to use SQLite for the event store.", score = 0.9f, turnId = "project:/repo")), + ) + val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), store) + + val flag = checker.check(newSession, stageId, "Use Postgres for the event store.") + + assertTrue(flag != null, "expected a flag") + assertEquals(newSession, flag!!.sessionId) + assertEquals(stageId, flag.stageId) + assertEquals("Use Postgres for the event store.", flag.decisionSummary) + assertEquals(1, flag.related.size) + val related = flag.related.single() + assertEquals("Decided to use SQLite for the event store.", related.summary) + assertEquals("project:/repo", related.source) + assertEquals(0.9, related.score, 1e-6) + } + + @Test + fun `returns null when there are no hits`() = runBlocking { + val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), CannedL3MemoryStore(emptyList())) + + assertNull(checker.check(newSession, stageId, "Use Postgres for the event store.")) + } + + @Test + fun `returns null when all hits are below threshold`() = runBlocking { + val store = CannedL3MemoryStore( + listOf(hit("Loosely related prior note.", score = 0.5f, turnId = "project:/repo")), + ) + val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), store, scoreThreshold = 0.75) + + assertNull(checker.check(newSession, stageId, "Use Postgres for the event store.")) + } + + @Test + fun `filters out hits outside the decision namespace`() = runBlocking { + val store = CannedL3MemoryStore( + listOf( + // Above threshold but a repo-map entry, not a decision — must be excluded. + hit("Foo.kt: ClassA, funcB", score = 0.95f, turnId = "repomap:/repo:abc"), + ), + ) + val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), store) + + assertNull(checker.check(newSession, stageId, "Use Postgres for the event store.")) + } + + @Test + fun `filters out the architect's own in-flight session`() = runBlocking { + val store = CannedL3MemoryStore( + listOf(hit("Same-session decision.", score = 0.95f, turnId = "project:/repo", sessionId = newSession)), + ) + val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), store) + + assertNull(checker.check(newSession, stageId, "Use Postgres for the event store.")) + } +} diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/ContradictionEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/ContradictionEvents.kt new file mode 100644 index 00000000..aaf5b554 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/ContradictionEvents.kt @@ -0,0 +1,33 @@ +package com.correx.core.events.events + +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * One prior decision/ADR surfaced as semantically near a new architect decision. + * [source] is the L3 entry's turnId (or a decision id) the line came from; [score] is the + * similarity of the prior decision to the new one. + */ +@Serializable +data class RelatedDecision( + val summary: String, + val score: Double, + val source: String, +) + +/** + * Display-only architect contradiction surfacing (BACKLOG §B-§4). Lists prior decisions + * semantically near the new one so the operator can spot a reversal. Non-blocking: this event + * only surfaces similarity candidates for an operator to eyeball — it never halts or fails a + * stage, and there is no LLM judge. + */ +@Serializable +@SerialName("PossibleContradictionFlagged") +data class PossibleContradictionFlaggedEvent( + val sessionId: SessionId, + val stageId: StageId, + val decisionSummary: String, + val related: List, +) : EventPayload diff --git a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt index 32fb6e6a..ddf42d40 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt @@ -20,6 +20,7 @@ import com.correx.core.events.events.OperatorProfileBoundEvent import com.correx.core.events.events.ProjectProfileBoundEvent import com.correx.core.events.events.SessionWorkspaceBoundEvent import com.correx.core.events.events.ContextTruncatedEvent +import com.correx.core.events.events.PossibleContradictionFlaggedEvent import com.correx.core.events.events.EventPayload import com.correx.core.events.events.ExecutionPlanLockedEvent import com.correx.core.events.events.ExecutionPlanRejectedEvent @@ -135,6 +136,7 @@ val eventModule = SerializersModule { subclass(CritiqueOutcomeCorrelatedEvent::class) subclass(StageCheckpointPassedEvent::class) subclass(StageCheckpointFailedEvent::class) + subclass(PossibleContradictionFlaggedEvent::class) } } diff --git a/core/events/src/test/kotlin/com/correx/core/events/serialization/ContradictionEventSerializationTest.kt b/core/events/src/test/kotlin/com/correx/core/events/serialization/ContradictionEventSerializationTest.kt new file mode 100644 index 00000000..84fa47dd --- /dev/null +++ b/core/events/src/test/kotlin/com/correx/core/events/serialization/ContradictionEventSerializationTest.kt @@ -0,0 +1,36 @@ +package com.correx.core.events.serialization + +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.PossibleContradictionFlaggedEvent +import com.correx.core.events.events.RelatedDecision +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ContradictionEventSerializationTest { + + @Test + fun `PossibleContradictionFlaggedEvent round-trips as polymorphic EventPayload`() { + val sample: EventPayload = PossibleContradictionFlaggedEvent( + sessionId = SessionId("s"), + stageId = StageId("architect"), + decisionSummary = "Use Postgres for the event store.", + related = listOf( + RelatedDecision( + summary = "Decided to use SQLite for the event store.", + score = 0.91, + source = "project:/repo", + ), + ), + ) + val encoded = eventJson.encodeToString(EventPayload.serializer(), sample) + assertTrue( + encoded.contains("\"type\":\"PossibleContradictionFlagged\""), + "SerialName must be present: $encoded", + ) + val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded) + assertEquals(sample, decoded) + } +}