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:
2026-07-06 13:11:16 +04:00
parent 28e9e698a1
commit 9f12c87bb1
15 changed files with 547 additions and 2 deletions
@@ -71,6 +71,7 @@ import com.correx.core.toolintent.rules.ReferenceExistsRule
import com.correx.core.toolintent.rules.StaleWriteRule
import com.correx.core.toolintent.rules.WriteScopeRule
import com.correx.apps.server.freestyle.FreestyleDriver
import com.correx.apps.server.inference.SemanticReviewerImpl
import com.correx.apps.server.inference.summarizeWithInference
import com.correx.core.kernel.orchestration.JournalCompactionService
import com.correx.apps.server.personalization.ProfileAdaptationService
@@ -326,6 +327,7 @@ fun main() {
workspaceToolRegistryProvider = wsToolRegistryProvider,
staticAnalysisRunner = ProcessStaticAnalysisRunner(),
contractAssertionEvaluator = FileSystemContractEvaluator(),
semanticReviewer = SemanticReviewerImpl(inferenceRouter),
)
val decisionJournalRepository = InfrastructureModule.createDecisionJournalRepository(eventStore)
val defaultOrchestrationConfig = OrchestrationConfig(
@@ -366,9 +368,17 @@ fun main() {
// task_context tool and GET /tasks/{id}/context return semantically-relevant snippets.
taskKnowledgeRetriever.delegate =
repoKnowledgeRetriever?.let { com.correx.apps.server.memory.RepoKnowledgeTaskRetriever(it) }
// Plan-compile gate: wrap the ExecutionPlanCompiler as a post-stage check so a compile-invalid
// architect plan is handed back through the retry-feedback loop instead of parking the session in
// ACTIVE (post-planning compile dead-end). Returns null on success, the compiler message on failure.
val planCompiler = ExecutionPlanCompiler(artifactKindRegistry, toolRegistry.all().map { it.name }.toSet())
val orchestrator = DefaultSessionOrchestrator(
repositories = repositories,
engines = engines,
engines = engines.copy(
planCompilationCheck = { json, candidateId ->
runCatching { planCompiler.compile(json, candidateId) }.exceptionOrNull()?.message
},
),
retryCoordinator = DefaultRetryCoordinator(eventStore),
artifactStore = artifactStore,
tokenizer = firstProvider.tokenizer,
@@ -491,7 +501,7 @@ fun main() {
val freestyleDriver = FreestyleDriver(
eventStore = eventStore,
compiler = ExecutionPlanCompiler(artifactKindRegistry, toolRegistry.all().map { it.name }.toSet()),
compiler = planCompiler,
planContent = { sid -> orchestrator.validatedArtifactContent(sid, ArtifactId("execution_plan")) },
config = defaultOrchestrationConfig,
runPhase2 = { sid, graph, cfg -> orchestrator.run(sid, graph, cfg) },
@@ -0,0 +1,188 @@
package com.correx.apps.server.inference
import com.correx.core.context.model.CompressionMetadata
import com.correx.core.context.model.ContextEntry
import com.correx.core.context.model.ContextLayer
import com.correx.core.context.model.ContextPack
import com.correx.core.context.model.EntryRole
import com.correx.core.events.events.ReviewFinding
import com.correx.core.events.events.ReviewSeverity
import com.correx.core.events.events.ReviewVerdict
import com.correx.core.events.types.ContextEntryId
import com.correx.core.events.types.ContextPackId
import com.correx.core.events.types.InferenceRequestId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.inference.GenerationConfig
import com.correx.core.inference.InferenceRequest
import com.correx.core.inference.InferenceRouter
import com.correx.core.inference.ModelCapability
import com.correx.core.inference.ResponseFormat
import com.correx.core.kernel.orchestration.ReviewOutcome
import com.correx.core.kernel.orchestration.SemanticReviewer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.slf4j.LoggerFactory
import java.nio.file.Path
import java.util.UUID
import kotlin.io.path.readText
private const val MAX_FILE_CHARS = 8_000
private const val MAX_TOTAL_CHARS = 24_000
// Reviewer runs on a reasoning-capable model (gemma) that spends a large token budget thinking
// before it emits the final JSON verdict. 1024 truncated it mid-reasoning → empty content →
// parse degraded to PASS. Give it real headroom so it reaches the JSON.
private const val REVIEW_MAX_TOKENS = 8_192
/**
* Gate 3 reviewer implementation: reads the stage's actually-produced files from the workspace (the
* FileWritten manifest — never blind context), asks a review-capable model for PR-comment-style
* findings as JSON, and maps them to [ReviewOutcome]. Talks to the provider directly with a
* self-contained prompt (like [summarizeWithInference]) rather than the orchestrator's contextPack
* machinery. Any failure (unroutable, timeout, unparseable output) degrades to a PASS with no
* findings — the reviewer is advisory and must never wedge a stage on its own error.
*/
class SemanticReviewerImpl(
private val inferenceRouter: InferenceRouter,
private val modelId: String? = null,
) : SemanticReviewer {
override suspend fun review(
sessionId: SessionId,
stageId: StageId,
workspaceRoot: Path,
files: List<String>,
objective: String,
): ReviewOutcome {
val bundle = renderFiles(workspaceRoot, files)
if (bundle.isBlank()) return ReviewOutcome(ReviewVerdict.PASS, emptyList())
return runCatching {
val provider = inferenceRouter.route(stageId, setOf(ModelCapability.General), modelId)
val prompt = buildPrompt(objective, bundle)
val request = InferenceRequest(
requestId = InferenceRequestId(UUID.randomUUID().toString()),
sessionId = sessionId,
stageId = stageId,
contextPack = pack(sessionId, stageId, prompt),
generationConfig = GenerationConfig(temperature = 0.2, topP = 0.9, maxTokens = REVIEW_MAX_TOKENS),
responseFormat = ResponseFormat.Text,
)
parse(provider.infer(request).text)
}.getOrElse {
log.warn(
"[SemanticReviewer] review failed session={} stage={}: {}",
sessionId.value, stageId.value, it.message,
)
ReviewOutcome(ReviewVerdict.PASS, emptyList())
}
}
private fun renderFiles(workspaceRoot: Path, files: List<String>): String {
val sb = StringBuilder()
for (rel in files) {
if (sb.length >= MAX_TOTAL_CHARS) break
val text = runCatching { workspaceRoot.resolve(rel).readText() }.getOrNull()
if (text != null) {
sb.append("=== FILE: ").append(rel).append(" ===\n")
.append(text.take(MAX_FILE_CHARS)).append("\n\n")
}
}
return sb.toString().take(MAX_TOTAL_CHARS)
}
private fun buildPrompt(objective: String, bundle: String): String =
"""
You are a senior code reviewer performing a PR review. The deterministic checks (compile,
contract, build) have ALREADY passed — do NOT report anything a compiler or linter would catch.
Review ONLY for correctness bugs, logic errors, security issues, and clear design/smell problems.
STAGE OBJECTIVE:
$objective
FILES PRODUCED:
$bundle
Respond with ONLY a JSON object, no prose, no markdown fence:
{"verdict":"PASS|WARN|FAIL","findings":[
{"severity":"CRITICAL|MAJOR|MINOR|NIT","confidence":0.0-1.0,"category":"correctness|smell|architecture|naming|security",
"target":"path/to/file.ext","message":"what is wrong","suggestedFix":"how to fix (optional)","correctness":true|false}
]}
Set correctness=true only when the code is actually WRONG (a confirmable bug), false for
subjective/suboptimal opinions. Use verdict FAIL only when at least one correctness finding is
high-confidence. Empty findings + PASS when the code is sound.
""".trimIndent()
private fun pack(sessionId: SessionId, stageId: StageId, prompt: String) = ContextPack(
id = ContextPackId(UUID.randomUUID().toString()),
sessionId = sessionId,
stageId = stageId,
layers = mapOf(
ContextLayer.L0 to listOf(
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L0,
content = prompt,
sourceType = "semantic_review",
sourceId = "reviewer",
tokenEstimate = prompt.length / 4,
role = EntryRole.USER,
),
),
),
budgetUsed = prompt.length / 4,
budgetLimit = prompt.length / 4,
compressionMetadata = CompressionMetadata(),
)
private fun parse(raw: String): ReviewOutcome {
val pass = ReviewOutcome(ReviewVerdict.PASS, emptyList())
val dto = extractJson(raw)
?.let { json -> runCatching { lenient.decodeFromString(ReviewDto.serializer(), json) }.getOrNull() }
?: return pass
val verdict = runCatching { ReviewVerdict.valueOf(dto.verdict.uppercase()) }.getOrDefault(ReviewVerdict.PASS)
val findings = dto.findings.map { f ->
ReviewFinding(
severity = runCatching { ReviewSeverity.valueOf(f.severity.uppercase()) }
.getOrDefault(ReviewSeverity.MINOR),
confidence = f.confidence.coerceIn(0.0, 1.0),
category = f.category.ifBlank { "correctness" },
target = f.target,
message = f.message,
suggestedFix = f.suggestedFix?.takeIf { it.isNotBlank() },
correctness = f.correctness,
)
}
return ReviewOutcome(verdict, findings)
}
/** Strip an optional ```json fence and slice from the first { to the last } — tolerant of prose wrap. */
private fun extractJson(raw: String): String? {
val start = raw.indexOf('{')
val end = raw.lastIndexOf('}')
return if (start in 0 until end) raw.substring(start, end + 1) else null
}
@Serializable
private data class ReviewDto(
val verdict: String = "PASS",
val findings: List<FindingDto> = emptyList(),
)
@Serializable
private data class FindingDto(
val severity: String = "MINOR",
val confidence: Double = 0.5,
val category: String = "correctness",
val target: String = "",
val message: String = "",
@SerialName("suggestedFix") val suggestedFix: String? = null,
val correctness: Boolean = false,
)
companion object {
private val log = LoggerFactory.getLogger(SemanticReviewerImpl::class.java)
private val lenient = Json { ignoreUnknownKeys = true; isLenient = true }
}
}