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
@@ -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)
}
}
@@ -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)
}
}