diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/BriefEchoEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/BriefEchoEvents.kt new file mode 100644 index 00000000..a465d50d --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/BriefEchoEvents.kt @@ -0,0 +1,38 @@ +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 + +/** + * A planner's structured restatement of the brief it was handed: the files it must touch, the + * symbols it must reach, and the acceptance criteria it must satisfy. The harness diffs this echo + * against the original brief before any plan is generated, so a divergence halts at the cheapest + * point in the pipeline. Also reused as the diff input shape for both sides of the comparison. + */ +@Serializable +data class BriefEcho( + val referencedFiles: Set = emptySet(), + val symbols: Set = emptySet(), + val acceptanceCriteria: Set = emptySet(), +) + +/** + * A stage echoed the brief but the echo diverged from the original, so the plan is rejected + * before generation ("halt before plan generation"). Recorded so replay explains the halt. + * + * - [droppedFiles] / [droppedSymbols] / [droppedAcceptanceCriteria]: present in the original brief, + * missing from the echo — the planner failed to carry them forward. + * - [inventedFiles]: file paths in the echo that are not in the original brief — hallucinated paths. + */ +@Serializable +@SerialName("BriefEchoMismatch") +data class BriefEchoMismatchEvent( + val sessionId: SessionId, + val stageId: StageId, + val droppedFiles: Set, + val droppedSymbols: Set, + val droppedAcceptanceCriteria: Set, + val inventedFiles: Set, +) : 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 20d6ed08..08076475 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 @@ -8,6 +8,7 @@ import com.correx.core.events.events.ArtifactContentStoredEvent import com.correx.core.events.events.ArtifactCreatedEvent import com.correx.core.events.events.ArtifactValidatedEvent import com.correx.core.events.events.ArtifactValidatingEvent +import com.correx.core.events.events.BriefEchoMismatchEvent import com.correx.core.events.events.BriefGroundingCheckedEvent import com.correx.core.events.events.ChatSessionStartedEvent import com.correx.core.events.events.ClarificationAnsweredEvent @@ -109,6 +110,7 @@ val eventModule = SerializersModule { subclass(WorkspaceStateObservedEvent::class) subclass(RepoKnowledgeRetrievedEvent::class) subclass(BriefGroundingCheckedEvent::class) + subclass(BriefEchoMismatchEvent::class) subclass(RiskAssessedEvent::class) subclass(ChatSessionStartedEvent::class) subclass(ChatTurnEvent::class) diff --git a/core/events/src/test/kotlin/com/correx/core/events/serialization/BriefEchoEventSerializationTest.kt b/core/events/src/test/kotlin/com/correx/core/events/serialization/BriefEchoEventSerializationTest.kt new file mode 100644 index 00000000..f93be4d1 --- /dev/null +++ b/core/events/src/test/kotlin/com/correx/core/events/serialization/BriefEchoEventSerializationTest.kt @@ -0,0 +1,28 @@ +package com.correx.core.events.serialization + +import com.correx.core.events.events.BriefEchoMismatchEvent +import com.correx.core.events.events.EventPayload +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 BriefEchoEventSerializationTest { + + @Test + fun `BriefEchoMismatchEvent round-trips as polymorphic EventPayload`() { + val sample: EventPayload = BriefEchoMismatchEvent( + sessionId = SessionId("s"), + stageId = StageId("plan"), + droppedFiles = setOf("core/kernel/Foo.kt"), + droppedSymbols = setOf("Foo"), + droppedAcceptanceCriteria = setOf("must compile"), + inventedFiles = setOf("core/kernel/Bar.kt"), + ) + val encoded = eventJson.encodeToString(EventPayload.serializer(), sample) + assertTrue(encoded.contains("\"type\":\"BriefEchoMismatch\""), "SerialName must be present: $encoded") + val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded) + assertEquals(sample, decoded) + } +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/BriefEchoComparator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/BriefEchoComparator.kt new file mode 100644 index 00000000..358de585 --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/BriefEchoComparator.kt @@ -0,0 +1,59 @@ +package com.correx.core.kernel.orchestration + +import com.correx.core.events.events.BriefEcho + +/** + * The divergence between an original brief and a planner's echo of it. + * + * - [droppedFiles] / [droppedSymbols] / [droppedAcceptanceCriteria]: present in the original brief, + * missing from the echo — the planner failed to carry them forward. + * - [inventedFiles]: file paths in the echo not in the original — hallucinated paths. + * + * [hasMismatch] is conservative: see [BriefEchoComparator.compare]. + */ +internal data class BriefEchoDiff( + val droppedFiles: Set, + val droppedSymbols: Set, + val droppedAcceptanceCriteria: Set, + val inventedFiles: Set, +) { + /** + * A mismatch that should halt the stage. Only dropping a *required acceptance criterion* or + * *inventing a file path* qualifies: those are correctness-bearing. Merely dropping a file or + * symbol the plan may legitimately not echo is tolerated and does NOT halt. + */ + val hasMismatch: Boolean + get() = droppedAcceptanceCriteria.isNotEmpty() || inventedFiles.isNotEmpty() +} + +/** + * Diffs a planner's [BriefEcho] against the original brief's. Pure and deterministic. + * + * Diff semantics: + * - `dropped* = original.X - echo.X` (the planner failed to carry something forward). + * - `inventedFiles = echo.referencedFiles - original.referencedFiles` (hallucinated paths). + * + * Comparison is case-insensitive and trimmed for acceptance criteria and symbols (free-text the + * model may rephrase in casing/whitespace), and exact for file paths (a path is a path). + */ +internal object BriefEchoComparator { + + fun compare(original: BriefEcho, echo: BriefEcho): BriefEchoDiff { + val droppedFiles = original.referencedFiles - echo.referencedFiles + val inventedFiles = echo.referencedFiles - original.referencedFiles + return BriefEchoDiff( + droppedFiles = droppedFiles, + droppedSymbols = normalizedDrop(original.symbols, echo.symbols), + droppedAcceptanceCriteria = normalizedDrop(original.acceptanceCriteria, echo.acceptanceCriteria), + inventedFiles = inventedFiles, + ) + } + + /** Original entries whose normalized form is absent from the echo, returned in original form. */ + private fun normalizedDrop(original: Set, echo: Set): Set { + val echoKeys = echo.mapTo(HashSet()) { it.normalize() } + return original.filterTo(LinkedHashSet()) { it.normalize() !in echoKeys } + } + + private fun String.normalize(): String = trim().lowercase() +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/BriefEchoExtractor.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/BriefEchoExtractor.kt new file mode 100644 index 00000000..a2ae0e37 --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/BriefEchoExtractor.kt @@ -0,0 +1,61 @@ +package com.correx.core.kernel.orchestration + +import com.correx.core.events.events.BriefEcho +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * Extracts the structured [BriefEcho] from two artifact shapes the echo gate compares: + * + * - [fromEcho]: a planner's produced artifact, which restates the brief under a `brief_echo` key: + * `{"brief_echo": {"referenced_files": [...], "symbols": [...], "acceptance_criteria": [...]}}`. + * - [fromOriginalBrief]: the original (analyst) brief artifact, whose three dimensions are pulled + * best-effort — referenced files via [BriefReferenceExtractor], plus optional `acceptance_criteria` + * and `symbols` arrays. + * + * Both are deterministic and tolerant of missing keys, parsing with kotlinx-serialization Json + * like [BriefReferenceExtractor]. + */ +internal object BriefEchoExtractor { + + private const val BRIEF_ECHO_KEY = "brief_echo" + private const val REFERENCED_FILES_KEY = "referenced_files" + private const val SYMBOLS_KEY = "symbols" + private const val ACCEPTANCE_CRITERIA_KEY = "acceptance_criteria" + + /** Returns the `brief_echo` block as a [BriefEcho], or null if the artifact has no such key. */ + fun fromEcho(artifactJson: String): BriefEcho? { + val root = artifactJson.parseObjectOrNull() ?: return null + val echo = root[BRIEF_ECHO_KEY] as? JsonObject ?: return null + return BriefEcho( + referencedFiles = echo.stringSet(REFERENCED_FILES_KEY), + symbols = echo.stringSet(SYMBOLS_KEY), + acceptanceCriteria = echo.stringSet(ACCEPTANCE_CRITERIA_KEY), + ) + } + + /** The same three dimensions pulled from the original brief artifact, best-effort. */ + fun fromOriginalBrief(artifactJson: String): BriefEcho { + val root = artifactJson.parseObjectOrNull() + return BriefEcho( + referencedFiles = BriefReferenceExtractor.fileReferences(artifactJson).toSet(), + symbols = root?.stringSet(SYMBOLS_KEY) ?: emptySet(), + acceptanceCriteria = root?.stringSet(ACCEPTANCE_CRITERIA_KEY) ?: emptySet(), + ) + } + + private fun String.parseObjectOrNull(): JsonObject? = + runCatching { Json.parseToJsonElement(this) }.getOrNull() as? JsonObject + + /** Non-blank string leaves of the named array, in order; empty when the key is absent/non-array. */ + private fun JsonObject.stringSet(key: String): Set { + val array = this[key] as? JsonArray ?: return emptySet() + return array.mapNotNullTo(LinkedHashSet()) { it.stringOrNull()?.takeIf(String::isNotBlank) } + } + + private fun JsonElement.stringOrNull(): String? = + (this as? JsonPrimitive)?.takeIf { it.isString }?.content +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index cdfdd5f2..85bca6c1 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -28,6 +28,7 @@ import com.correx.core.events.events.RefinementIterationEvent import com.correx.core.events.events.ApprovalRequestedEvent import com.correx.core.events.events.ArtifactContentStoredEvent import com.correx.core.events.events.ArtifactCreatedEvent +import com.correx.core.events.events.BriefEchoMismatchEvent import com.correx.core.events.events.BriefGroundingCheckedEvent import com.correx.core.events.events.ContextTruncatedEvent import com.correx.core.events.events.ClarificationAnswer @@ -550,7 +551,11 @@ abstract class SessionOrchestrator( emitLlmArtifacts(sessionId, stageId, stageConfig) when (val produced = verifyProduces(sessionId, stageId, stageConfig)) { is StageExecutionResult.Success -> - groundBriefReferences(sessionId, stageId, stageConfig, effectives) + when (val grounded = groundBriefReferences(sessionId, stageId, stageConfig, effectives)) { + is StageExecutionResult.Success -> + checkBriefEcho(sessionId, stageId, stageConfig) + is StageExecutionResult.Failure -> grounded + } is StageExecutionResult.Failure -> produced } } @@ -1399,6 +1404,70 @@ abstract class SessionOrchestrator( } } + /** + * Brief echo-back gate (BACKLOG §C-A1): for a stage that opted in (`briefEcho`), the planner must + * restate the brief as a structured `brief_echo` block before any plan is generated. The echo is + * diffed against the original brief; on divergence a [BriefEchoMismatchEvent] is emitted and the + * stage fails (retryable), so the plan is never accepted — the cheapest failure point in the + * pipeline. Mismatch is conservative ([BriefEchoComparator]): a dropped acceptance criterion or an + * invented file path halts; merely dropping a file/symbol does not. + */ + private suspend fun checkBriefEcho( + sessionId: SessionId, + stageId: StageId, + stageConfig: StageConfig, + ): StageExecutionResult { + if (stageConfig.metadata["briefEcho"] != "true") return StageExecutionResult.Success(emptyList()) + val sourceSlot = stageConfig.metadata["briefEchoSource"] ?: "brief" + val original = artifactContentCache["${sessionId.value}:$sourceSlot"] + ?: return StageExecutionResult.Success(emptyList()) + + val produced = stageConfig.produces + .filter { it.kind.llmEmitted } + .firstNotNullOfOrNull { artifactContentCache["${sessionId.value}:${it.name.value}"] } + ?: return StageExecutionResult.Success(emptyList()) + + val echo = BriefEchoExtractor.fromEcho(produced) + ?: return StageExecutionResult.Failure( + "stage ${stageId.value} must restate the brief as a brief_echo block before planning", + retryable = true, + ) + + val diff = BriefEchoComparator.compare(BriefEchoExtractor.fromOriginalBrief(original), echo) + if (!diff.hasMismatch) return StageExecutionResult.Success(emptyList()) + + emit( + sessionId, + BriefEchoMismatchEvent( + sessionId = sessionId, + stageId = stageId, + droppedFiles = diff.droppedFiles, + droppedSymbols = diff.droppedSymbols, + droppedAcceptanceCriteria = diff.droppedAcceptanceCriteria, + inventedFiles = diff.inventedFiles, + ), + ) + log.warn( + "[Orchestrator] brief echo diverged session={} stage={} droppedCriteria={} invented={}", + sessionId.value, stageId.value, + diff.droppedAcceptanceCriteria.joinToString(", "), + diff.inventedFiles.joinToString(", "), + ) + return StageExecutionResult.Failure( + buildString { + append("stage ${stageId.value} brief_echo diverged from the brief.") + if (diff.droppedAcceptanceCriteria.isNotEmpty()) { + append(" Restate these acceptance criteria: ${diff.droppedAcceptanceCriteria.joinToString(", ")}.") + } + if (diff.inventedFiles.isNotEmpty()) { + append(" These files are not in the brief — do not invent paths: " + + "${diff.inventedFiles.joinToString(", ")}.") + } + }, + retryable = true, + ) + } + private suspend fun emitProcessResultEvents( sessionId: SessionId, stageId: StageId, diff --git a/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/BriefEchoComparatorTest.kt b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/BriefEchoComparatorTest.kt new file mode 100644 index 00000000..170813e8 --- /dev/null +++ b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/BriefEchoComparatorTest.kt @@ -0,0 +1,68 @@ +package com.correx.core.kernel.orchestration + +import com.correx.core.events.events.BriefEcho +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class BriefEchoComparatorTest { + + @Test + fun `identical echo has no mismatch`() { + val brief = BriefEcho( + referencedFiles = setOf("core/kernel/Foo.kt"), + symbols = setOf("Foo"), + acceptanceCriteria = setOf("must compile"), + ) + val diff = BriefEchoComparator.compare(brief, brief) + assertFalse(diff.hasMismatch) + assertTrue(diff.droppedFiles.isEmpty()) + assertTrue(diff.inventedFiles.isEmpty()) + } + + @Test + fun `dropped acceptance criterion is a mismatch`() { + val original = BriefEcho(acceptanceCriteria = setOf("must compile", "must pass tests")) + val echo = BriefEcho(acceptanceCriteria = setOf("must compile")) + val diff = BriefEchoComparator.compare(original, echo) + assertTrue(diff.hasMismatch) + assertEquals(setOf("must pass tests"), diff.droppedAcceptanceCriteria) + } + + @Test + fun `invented file is a mismatch`() { + val original = BriefEcho(referencedFiles = setOf("core/kernel/Foo.kt")) + val echo = BriefEcho(referencedFiles = setOf("core/kernel/Foo.kt", "core/kernel/Bar.kt")) + val diff = BriefEchoComparator.compare(original, echo) + assertTrue(diff.hasMismatch) + assertEquals(setOf("core/kernel/Bar.kt"), diff.inventedFiles) + } + + @Test + fun `dropped file only is not a mismatch`() { + val original = BriefEcho(referencedFiles = setOf("core/kernel/Foo.kt", "core/kernel/Bar.kt")) + val echo = BriefEcho(referencedFiles = setOf("core/kernel/Foo.kt")) + val diff = BriefEchoComparator.compare(original, echo) + assertFalse(diff.hasMismatch) + assertEquals(setOf("core/kernel/Bar.kt"), diff.droppedFiles) + } + + @Test + fun `acceptance criterion match is case-insensitive and trimmed`() { + val original = BriefEcho(acceptanceCriteria = setOf("Must Compile")) + val echo = BriefEcho(acceptanceCriteria = setOf(" must compile ")) + val diff = BriefEchoComparator.compare(original, echo) + assertFalse(diff.hasMismatch) + assertTrue(diff.droppedAcceptanceCriteria.isEmpty()) + } + + @Test + fun `dropped symbol is not a mismatch but is reported`() { + val original = BriefEcho(symbols = setOf("Foo", "Bar")) + val echo = BriefEcho(symbols = setOf("foo")) + val diff = BriefEchoComparator.compare(original, echo) + assertFalse(diff.hasMismatch) + assertEquals(setOf("Bar"), diff.droppedSymbols) + } +} diff --git a/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/BriefEchoExtractorTest.kt b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/BriefEchoExtractorTest.kt new file mode 100644 index 00000000..1f51f7c6 --- /dev/null +++ b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/BriefEchoExtractorTest.kt @@ -0,0 +1,62 @@ +package com.correx.core.kernel.orchestration + +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 + +class BriefEchoExtractorTest { + + @Test + fun `parses a well-formed brief_echo block`() { + val json = """ + { + "brief_echo": { + "referenced_files": ["core/kernel/Foo.kt", "core/kernel/Bar.kt"], + "symbols": ["Foo"], + "acceptance_criteria": ["must compile", "must pass tests"] + } + } + """.trimIndent() + val echo = BriefEchoExtractor.fromEcho(json)!! + assertEquals(setOf("core/kernel/Foo.kt", "core/kernel/Bar.kt"), echo.referencedFiles) + assertEquals(setOf("Foo"), echo.symbols) + assertEquals(setOf("must compile", "must pass tests"), echo.acceptanceCriteria) + } + + @Test + fun `returns null when brief_echo key is absent`() { + assertNull(BriefEchoExtractor.fromEcho("""{"summary": "do the thing"}""")) + } + + @Test + fun `tolerates missing dimensions in brief_echo`() { + val echo = BriefEchoExtractor.fromEcho("""{"brief_echo": {"referenced_files": ["a/b/C.kt"]}}""")!! + assertEquals(setOf("a/b/C.kt"), echo.referencedFiles) + assertTrue(echo.symbols.isEmpty()) + assertTrue(echo.acceptanceCriteria.isEmpty()) + } + + @Test + fun `original-brief extraction pulls referenced files and acceptance criteria`() { + val json = """ + { + "summary": "touch core/kernel/Foo.kt to satisfy the gate", + "acceptance_criteria": ["must compile", "must pass tests"], + "symbols": ["Foo"] + } + """.trimIndent() + val original = BriefEchoExtractor.fromOriginalBrief(json) + assertTrue(original.referencedFiles.contains("core/kernel/Foo.kt")) + assertEquals(setOf("must compile", "must pass tests"), original.acceptanceCriteria) + assertEquals(setOf("Foo"), original.symbols) + } + + @Test + fun `original-brief extraction tolerates missing acceptance_criteria and symbols`() { + val original = BriefEchoExtractor.fromOriginalBrief("""{"affected": ["core/kernel/Foo.kt"]}""") + assertTrue(original.referencedFiles.contains("core/kernel/Foo.kt")) + assertTrue(original.acceptanceCriteria.isEmpty()) + assertTrue(original.symbols.isEmpty()) + } +}