feat(memory): architect contradiction-check (B§4, display-only)

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 11:31:39 +00:00
parent 1a1b5cc5ed
commit eae0a0ccf6
6 changed files with 258 additions and 0 deletions
@@ -380,6 +380,16 @@ fun main() {
privilegedLocations = privilegedLocations, privilegedLocations = privilegedLocations,
allowedWorkspaceRoots = allowedWorkspaceRoots, 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:<repoRoot>", 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 // Built from a config snapshot and reused by ConfigService's rebuild hook so toggling
// project.enabled / personalization.* applies live to the next session. // project.enabled / personalization.* applies live to the next session.
fun buildProjectMemory(cfg: CorrexConfig): com.correx.apps.server.memory.ProjectMemoryService? = fun buildProjectMemory(cfg: CorrexConfig): com.correx.apps.server.memory.ProjectMemoryService? =
@@ -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:<repoRoot>"` (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:<repoRoot>:"` 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
}
}
@@ -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<L3Hit>) : L3MemoryStore {
override suspend fun store(entry: L3MemoryEntry) = Unit
override suspend fun query(query: L3Query): List<L3Hit> = 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."))
}
}
@@ -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<RelatedDecision>,
) : EventPayload
@@ -20,6 +20,7 @@ import com.correx.core.events.events.OperatorProfileBoundEvent
import com.correx.core.events.events.ProjectProfileBoundEvent import com.correx.core.events.events.ProjectProfileBoundEvent
import com.correx.core.events.events.SessionWorkspaceBoundEvent import com.correx.core.events.events.SessionWorkspaceBoundEvent
import com.correx.core.events.events.ContextTruncatedEvent 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.EventPayload
import com.correx.core.events.events.ExecutionPlanLockedEvent import com.correx.core.events.events.ExecutionPlanLockedEvent
import com.correx.core.events.events.ExecutionPlanRejectedEvent import com.correx.core.events.events.ExecutionPlanRejectedEvent
@@ -135,6 +136,7 @@ val eventModule = SerializersModule {
subclass(CritiqueOutcomeCorrelatedEvent::class) subclass(CritiqueOutcomeCorrelatedEvent::class)
subclass(StageCheckpointPassedEvent::class) subclass(StageCheckpointPassedEvent::class)
subclass(StageCheckpointFailedEvent::class) subclass(StageCheckpointFailedEvent::class)
subclass(PossibleContradictionFlaggedEvent::class)
} }
} }
@@ -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)
}
}