feat(kernel): brief echo-back gate (C-A1)
- BriefEcho model + BriefEchoMismatchEvent (registered + serialization test) - BriefEchoExtractor: parses the planner's brief_echo block and the original brief's referenced-files/symbols/acceptance-criteria dimensions - BriefEchoComparator: conservative diff — a dropped acceptance criterion or an invented file path halts; dropping a file/symbol alone does not - checkBriefEcho gate chained into the post-stage flow after groundBriefReferences; opt-in via stageConfig.metadata["briefEcho"], fails the stage (retryable) on divergence with re-grounding feedback - prompt/workflow activation (planner.md emitting brief_echo; role_pipeline metadata) left as live-QA follow-up Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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<String> = emptySet(),
|
||||
val symbols: Set<String> = emptySet(),
|
||||
val acceptanceCriteria: Set<String> = 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<String>,
|
||||
val droppedSymbols: Set<String>,
|
||||
val droppedAcceptanceCriteria: Set<String>,
|
||||
val inventedFiles: Set<String>,
|
||||
) : EventPayload
|
||||
@@ -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)
|
||||
|
||||
+28
@@ -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)
|
||||
}
|
||||
}
|
||||
+59
@@ -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<String>,
|
||||
val droppedSymbols: Set<String>,
|
||||
val droppedAcceptanceCriteria: Set<String>,
|
||||
val inventedFiles: Set<String>,
|
||||
) {
|
||||
/**
|
||||
* 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<String>, echo: Set<String>): Set<String> {
|
||||
val echoKeys = echo.mapTo(HashSet()) { it.normalize() }
|
||||
return original.filterTo(LinkedHashSet()) { it.normalize() !in echoKeys }
|
||||
}
|
||||
|
||||
private fun String.normalize(): String = trim().lowercase()
|
||||
}
|
||||
+61
@@ -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<String> {
|
||||
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
|
||||
}
|
||||
+70
-1
@@ -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,
|
||||
|
||||
+68
@@ -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)
|
||||
}
|
||||
}
|
||||
+62
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user