feat(validation): plan-compile gate + semantic-review gate (staged verification #19/#20)
Plan-compile gate (#19): PlanCompilationCheck seam (null-on-replay), runPlanCompileGate guards a produced execution_plan slot and compiles its cached content via ExecutionPlanCompiler, emitting PlanCompileCheckedEvent. On failure the architect gets a retryable hand-back and self-corrects; exhaustion terminates the dead-end workflow instead of shipping an uncompilable plan. Semantic-review gate (#20): SemanticReviewer seam + StageConfig.semanticReview opt-in (TOML semantic_review + freestyle plan JSON). runReviewGate reads the FileWritten manifest (never blind context), asks a review-capable model for PR-comment findings, and is advisory — blocks retryably only on FAIL plus a correctness finding >=0.7 confidence, capped at 2 blocks so a stubborn stage completes advisory-only rather than trapping. SemanticReviewerImpl talks to the provider directly; any reviewer error degrades to PASS. REVIEW_MAX_TOKENS is 8192: gemma is a reasoning model and 1024 truncated it mid-reasoning before it reached the JSON verdict (empty content -> parse degraded to PASS). Both gates live-QA'd on the real repo: plan-compile passed on a freestyle web-UI scaffold; semantic review caught all 3 planted bugs in a maxOf() and exercised the block->retry->cap safety valve end-to-end.
This commit is contained in:
@@ -2,6 +2,7 @@ package com.correx.core.events.events
|
||||
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@@ -34,3 +35,20 @@ data class ExecutionPlanRejectedEvent(
|
||||
/** "compile" | "missing_content" | "operator" */
|
||||
val source: String,
|
||||
) : EventPayload
|
||||
|
||||
/**
|
||||
* Environment observation (invariant #9): the outcome of compiling a stage's produced execution_plan
|
||||
* as a post-stage gate. Recorded at the moment the architect stage completes so replay reads the
|
||||
* recorded verdict and never re-compiles. A non-null [error] fails the producing stage retryably,
|
||||
* feeding the compiler message back so the architect self-corrects via the retry-feedback loop
|
||||
* (rather than parking the session in ACTIVE forever, as a post-planning compile failure did).
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("PlanCompileChecked")
|
||||
data class PlanCompileCheckedEvent(
|
||||
val sessionId: SessionId,
|
||||
val stageId: StageId,
|
||||
val passed: Boolean,
|
||||
/** Compiler error when [passed] is false; null on success. Kept bounded. */
|
||||
val error: String? = null,
|
||||
) : EventPayload
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
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
|
||||
|
||||
/** Overall verdict of a semantic (LLM) review over a stage's produced files. */
|
||||
enum class ReviewVerdict { PASS, WARN, FAIL }
|
||||
|
||||
/** Severity of a single review finding, high→low. */
|
||||
enum class ReviewSeverity { CRITICAL, MAJOR, MINOR, NIT }
|
||||
|
||||
/**
|
||||
* One PR-comment-style finding from the semantic reviewer (Gate 3). A finding is a proposal, not a
|
||||
* fact — [confidence] is the reviewer's own 0..1 self-estimate and [correctness] marks findings that
|
||||
* claim the code is *wrong* (deterministically re-confirmable) vs. merely *suboptimal* (a smell /
|
||||
* style / architecture opinion that can never block on its own).
|
||||
*/
|
||||
@Serializable
|
||||
data class ReviewFinding(
|
||||
val severity: ReviewSeverity,
|
||||
/** Reviewer self-estimated confidence, 0.0..1.0. */
|
||||
val confidence: Double,
|
||||
/** Short category slug, e.g. "correctness", "smell", "architecture", "naming". */
|
||||
val category: String,
|
||||
/** Workspace-relative file (optionally :line) the finding anchors to. */
|
||||
val target: String,
|
||||
/** What is wrong / suboptimal. Kept bounded by the producer. */
|
||||
val message: String,
|
||||
/** Suggested fix, when the reviewer offers one. */
|
||||
val suggestedFix: String? = null,
|
||||
/** True = claims the code is incorrect (confirmable); false = suboptimal opinion (never blocks). */
|
||||
val correctness: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Environment observation (invariant #9): the semantic reviewer's findings over a stage's produced
|
||||
* files, captured at the moment the stage completed. The reviewer is advisory — it reads the actual
|
||||
* FileWritten manifest (never blind context) and its findings surface to the operator regardless of
|
||||
* verdict. A [ReviewVerdict.FAIL] carrying a high-confidence correctness finding can block the stage
|
||||
* retryably, but only until the review-block retry budget is spent, after which findings surface
|
||||
* without blocking. Replay reads these recorded facts and never re-runs the reviewer.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("ReviewFindingsRaised")
|
||||
data class ReviewFindingsRaisedEvent(
|
||||
val sessionId: SessionId,
|
||||
val stageId: StageId,
|
||||
val verdict: ReviewVerdict,
|
||||
val findings: List<ReviewFinding>,
|
||||
/** True when this review's FAIL verdict blocked the stage (fed back for a fix); false = advisory. */
|
||||
val blocked: Boolean = false,
|
||||
) : EventPayload
|
||||
@@ -33,6 +33,8 @@ import com.correx.core.events.events.PossibleContradictionFlaggedEvent
|
||||
import com.correx.core.events.events.EgressHostsGrantedEvent
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.ExecutionPlanLockedEvent
|
||||
import com.correx.core.events.events.PlanCompileCheckedEvent
|
||||
import com.correx.core.events.events.ReviewFindingsRaisedEvent
|
||||
import com.correx.core.events.events.ExecutionPlanRejectedEvent
|
||||
import com.correx.core.events.events.HealthDegradedEvent
|
||||
import com.correx.core.events.events.HealthRestoredEvent
|
||||
@@ -164,6 +166,8 @@ val eventModule = SerializersModule {
|
||||
subclass(ContextTruncatedEvent::class)
|
||||
subclass(ExecutionPlanLockedEvent::class)
|
||||
subclass(ExecutionPlanRejectedEvent::class)
|
||||
subclass(PlanCompileCheckedEvent::class)
|
||||
subclass(ReviewFindingsRaisedEvent::class)
|
||||
subclass(JournalCompactedEvent::class)
|
||||
subclass(HealthDegradedEvent::class)
|
||||
subclass(HealthRestoredEvent::class)
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package com.correx.core.events.serialization
|
||||
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.PlanCompileCheckedEvent
|
||||
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 PlanCompileCheckedEventSerializationTest {
|
||||
|
||||
@Test
|
||||
fun `PlanCompileCheckedEvent round-trips as polymorphic EventPayload`() {
|
||||
val sample: EventPayload = PlanCompileCheckedEvent(
|
||||
sessionId = SessionId("s"),
|
||||
stageId = StageId("architect"),
|
||||
passed = false,
|
||||
error = "edge from unknown stage 'ghost'",
|
||||
)
|
||||
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
|
||||
assertTrue(encoded.contains("\"type\":\"PlanCompileChecked\""), "SerialName must be present: $encoded")
|
||||
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
|
||||
assertEquals(sample, decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PlanCompileCheckedEvent round-trips on success with null error`() {
|
||||
val sample: EventPayload = PlanCompileCheckedEvent(
|
||||
sessionId = SessionId("s"),
|
||||
stageId = StageId("architect"),
|
||||
passed = true,
|
||||
)
|
||||
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
|
||||
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
|
||||
assertEquals(sample, decoded)
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package com.correx.core.events.serialization
|
||||
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.ReviewFinding
|
||||
import com.correx.core.events.events.ReviewFindingsRaisedEvent
|
||||
import com.correx.core.events.events.ReviewSeverity
|
||||
import com.correx.core.events.events.ReviewVerdict
|
||||
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 ReviewFindingsRaisedEventSerializationTest {
|
||||
|
||||
@Test
|
||||
fun `ReviewFindingsRaisedEvent round-trips as polymorphic EventPayload`() {
|
||||
val sample: EventPayload = ReviewFindingsRaisedEvent(
|
||||
sessionId = SessionId("s"),
|
||||
stageId = StageId("implement"),
|
||||
verdict = ReviewVerdict.FAIL,
|
||||
findings = listOf(
|
||||
ReviewFinding(
|
||||
severity = ReviewSeverity.CRITICAL,
|
||||
confidence = 0.9,
|
||||
category = "correctness",
|
||||
target = "src/App.tsx:42",
|
||||
message = "off-by-one in the loop bound",
|
||||
suggestedFix = "use < instead of <=",
|
||||
correctness = true,
|
||||
),
|
||||
),
|
||||
blocked = true,
|
||||
)
|
||||
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
|
||||
assertTrue(encoded.contains("\"type\":\"ReviewFindingsRaised\""), "SerialName must be present: $encoded")
|
||||
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
|
||||
assertEquals(sample, decoded)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user