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:
@@ -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:<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
|
||||
// project.enabled / personalization.* applies live to the next session.
|
||||
fun buildProjectMemory(cfg: CorrexConfig): com.correx.apps.server.memory.ProjectMemoryService? =
|
||||
|
||||
+70
@@ -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
|
||||
}
|
||||
}
|
||||
+107
@@ -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.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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+36
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user