feat(workflow): deterministic plan lint (plan-pipeline §5, Slice 1)
First slice of the plan-generation pipeline epic. A pure-Kotlin PlanLinter runs over the compiled freestyle ExecutionPlan (WorkflowGraph) before it is locked, complementing ExecutionPlanCompiler (which throws on unreachable/unknown-kind/bad-edge) with the checks it does NOT make: - HARD unproduced_need — a stage needs an artifact no stage produces. Real gap: only the TOML loader checked this; the freestyle compiler did not, so such plans compiled and failed at runtime. - HARD trap_state — a stage with no path to the terminal (inescapable loop / dead end). Uses terminal-reachability, NOT "any cycle", so legitimate verdict-gated review loops pass. - SOFT stage_count / fan_out / empty_brief / duplicate_brief — scored, never blocking. FreestyleDriver lints after compile and records PlanLintCompletedEvent (pure function of the recorded plan → replay-safe, no env observation in v1); a hard failure rejects the plan (ExecutionPlanRejectedEvent source="lint") before the operator is asked or the plan locks, so a broken plan never executes. Pays off now for single-plan freestyle; becomes the per-candidate filter when multi-candidate generation (Slice 2) lands. Deferred to later slices: file-path grounding + symbol resolution (needs an index), token-budget/ADR-bypass checks, and a dedicated :core:planning module.
This commit is contained in:
+140
@@ -0,0 +1,140 @@
|
||||
package com.correx.infrastructure.workflow
|
||||
|
||||
import com.correx.core.events.events.PlanLintFinding
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.transitions.graph.StageConfig
|
||||
import com.correx.core.transitions.graph.WorkflowGraph
|
||||
|
||||
/**
|
||||
* Result of linting a compiled ExecutionPlan. [passed] is false when any hard failure is present —
|
||||
* such a candidate is unfit and must not be locked/run. [score] is the weighted soft-finding penalty
|
||||
* (lower is better) used by the future tournament ranking (plan-pipeline-spec §9).
|
||||
*/
|
||||
data class PlanLintResult(
|
||||
val hardFailures: List<PlanLintFinding>,
|
||||
val softFindings: List<PlanLintFinding>,
|
||||
) {
|
||||
val passed: Boolean get() = hardFailures.isEmpty()
|
||||
val score: Int get() = softFindings.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic, pure-Kotlin lint over a compiled plan graph (plan-pipeline-spec §5). Zero inference,
|
||||
* zero I/O — a function of the graph alone, so it is replay-safe by construction. It complements
|
||||
* [ExecutionPlanCompiler] (which already throws on unreachable-from-start / unknown-kind / bad-edge):
|
||||
* the lint adds the checks the compiler does NOT make.
|
||||
*
|
||||
* v1 is graph-structural only. File-path grounding and symbol resolution are deferred (symbols need a
|
||||
* real index; file grounding overlaps the analyst-brief grounding step).
|
||||
*/
|
||||
object PlanLinter {
|
||||
|
||||
private const val STAGE_CEILING = 12
|
||||
private const val FAN_OUT_THRESHOLD = 4
|
||||
|
||||
fun lint(graph: WorkflowGraph): PlanLintResult =
|
||||
PlanLintResult(
|
||||
hardFailures = unproducedNeeds(graph) + trapStates(graph),
|
||||
softFindings = stageCount(graph) + fanOut(graph) + emptyBriefs(graph) + duplicateBriefs(graph),
|
||||
)
|
||||
|
||||
/** H1: a stage `needs` an artifact that no stage `produces`. */
|
||||
private fun unproducedNeeds(graph: WorkflowGraph): List<PlanLintFinding> {
|
||||
val produced = graph.stages.values.flatMap { it.produces }.map { it.name.value }.toSet()
|
||||
return graph.stages.entries.flatMap { (id, stage) ->
|
||||
stage.needs.map { it.value }.filterNot { it in produced }.map { need ->
|
||||
PlanLintFinding(
|
||||
code = "unproduced_need",
|
||||
stageId = id.value,
|
||||
detail = "needs artifact '$need', which no stage produces",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* H2: a stage with no path to the terminal (`done` / any edge target not in `stages`). Subsumes
|
||||
* inescapable cycles and dead-end sinks — only "no exit" fails, so legitimate verdict-gated review
|
||||
* loops (which DO have an exit edge) pass.
|
||||
*/
|
||||
private fun trapStates(graph: WorkflowGraph): List<PlanLintFinding> {
|
||||
val outTargets: Map<StageId, List<StageId>> =
|
||||
graph.transitions.groupBy({ it.from }, { it.to })
|
||||
val escapable = mutableSetOf<StageId>()
|
||||
var changed = true
|
||||
while (changed) {
|
||||
changed = false
|
||||
for (id in graph.stages.keys) {
|
||||
if (id in escapable) continue
|
||||
val targets = outTargets[id].orEmpty()
|
||||
if (targets.any { it !in graph.stages || it in escapable }) {
|
||||
escapable.add(id)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return (graph.stages.keys - escapable).map { id ->
|
||||
PlanLintFinding(
|
||||
code = "trap_state",
|
||||
stageId = id.value,
|
||||
detail = "no path to the terminal — an inescapable loop or dead-end stage",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** S1: more stages than the ceiling. */
|
||||
private fun stageCount(graph: WorkflowGraph): List<PlanLintFinding> =
|
||||
if (graph.stages.size > STAGE_CEILING) {
|
||||
listOf(
|
||||
PlanLintFinding(
|
||||
code = "stage_count",
|
||||
stageId = null,
|
||||
detail = "${graph.stages.size} stages exceeds the ceiling of $STAGE_CEILING",
|
||||
),
|
||||
)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
/** S2: a stage with more outgoing edges than the fan-out threshold. */
|
||||
private fun fanOut(graph: WorkflowGraph): List<PlanLintFinding> {
|
||||
val outDegree = graph.transitions.groupingBy { it.from }.eachCount()
|
||||
return graph.stages.keys.mapNotNull { id ->
|
||||
val degree = outDegree[id] ?: 0
|
||||
if (degree > FAN_OUT_THRESHOLD) {
|
||||
PlanLintFinding(
|
||||
code = "fan_out",
|
||||
stageId = id.value,
|
||||
detail = "$degree outgoing edges exceeds the threshold of $FAN_OUT_THRESHOLD",
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** S3: a stage with a blank inline brief. */
|
||||
private fun emptyBriefs(graph: WorkflowGraph): List<PlanLintFinding> =
|
||||
graph.stages.entries.filter { (_, stage) -> briefOf(stage).isBlank() }.map { (id, _) ->
|
||||
PlanLintFinding(code = "empty_brief", stageId = id.value, detail = "stage has an empty inline brief")
|
||||
}
|
||||
|
||||
/** S4: two or more stages sharing an identical normalized brief. */
|
||||
private fun duplicateBriefs(graph: WorkflowGraph): List<PlanLintFinding> =
|
||||
graph.stages.entries
|
||||
.filterNot { (_, stage) -> briefOf(stage).isBlank() }
|
||||
.groupBy({ (_, stage) -> normalize(briefOf(stage)) }, { (id, _) -> id.value })
|
||||
.values
|
||||
.filter { it.size > 1 }
|
||||
.map { ids ->
|
||||
PlanLintFinding(
|
||||
code = "duplicate_brief",
|
||||
stageId = null,
|
||||
detail = "stages share an identical brief: ${ids.sorted().joinToString(", ")}",
|
||||
)
|
||||
}
|
||||
|
||||
private fun briefOf(stage: StageConfig): String = stage.metadata["promptInline"] ?: ""
|
||||
|
||||
private fun normalize(text: String): String = text.trim().lowercase().replace(Regex("\\s+"), " ")
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
package com.correx.infrastructure.workflow
|
||||
|
||||
import com.correx.core.artifacts.kind.ConfigArtifactKind
|
||||
import com.correx.core.artifacts.kind.JsonSchema
|
||||
import com.correx.core.artifacts.kind.TypedArtifactSlot
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.TransitionId
|
||||
import com.correx.core.transitions.conditions.AlwaysTrue
|
||||
import com.correx.core.transitions.graph.StageConfig
|
||||
import com.correx.core.transitions.graph.TransitionEdge
|
||||
import com.correx.core.transitions.graph.WorkflowGraph
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class PlanLinterTest {
|
||||
|
||||
private fun kind(id: String) =
|
||||
ConfigArtifactKind(id, JsonSchema(type = "object", properties = emptyMap(), additionalProperties = true))
|
||||
|
||||
private fun stage(
|
||||
produces: List<String> = emptyList(),
|
||||
needs: List<String> = emptyList(),
|
||||
prompt: String = "do the thing",
|
||||
) = StageConfig(
|
||||
produces = produces.map { TypedArtifactSlot(ArtifactId(it), kind(it)) },
|
||||
needs = needs.map { ArtifactId(it) }.toSet(),
|
||||
metadata = mapOf("promptInline" to prompt),
|
||||
)
|
||||
|
||||
private fun graph(
|
||||
stages: Map<String, StageConfig>,
|
||||
edges: List<Pair<String, String>>,
|
||||
start: String,
|
||||
) = WorkflowGraph(
|
||||
id = "test",
|
||||
stages = stages.mapKeys { StageId(it.key) },
|
||||
transitions = edges.mapIndexed { i, (f, t) ->
|
||||
TransitionEdge(TransitionId("e$i"), StageId(f), StageId(t), AlwaysTrue)
|
||||
}.toSet(),
|
||||
start = StageId(start),
|
||||
)
|
||||
|
||||
private fun codes(findings: List<com.correx.core.events.events.PlanLintFinding>) = findings.map { it.code }.toSet()
|
||||
|
||||
@Test
|
||||
fun `a clean linear plan passes with no findings`() {
|
||||
val g = graph(
|
||||
stages = mapOf(
|
||||
"a" to stage(produces = listOf("x"), prompt = "analyze"),
|
||||
"b" to stage(produces = listOf("y"), needs = listOf("x"), prompt = "build"),
|
||||
),
|
||||
edges = listOf("a" to "b", "b" to "done"),
|
||||
start = "a",
|
||||
)
|
||||
val result = PlanLinter.lint(g)
|
||||
assertTrue(result.passed, "expected pass, hard=${result.hardFailures}")
|
||||
assertTrue(result.softFindings.isEmpty(), "expected no soft, got ${result.softFindings}")
|
||||
assertEquals(0, result.score)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a need produced by no stage is a hard failure`() {
|
||||
val g = graph(
|
||||
stages = mapOf(
|
||||
"a" to stage(produces = listOf("x"), prompt = "analyze"),
|
||||
"b" to stage(needs = listOf("x", "z"), prompt = "build"),
|
||||
),
|
||||
edges = listOf("a" to "b", "b" to "done"),
|
||||
start = "a",
|
||||
)
|
||||
val result = PlanLinter.lint(g)
|
||||
assertFalse(result.passed)
|
||||
val h = result.hardFailures.single { it.code == "unproduced_need" }
|
||||
assertEquals("b", h.stageId)
|
||||
assertTrue(h.detail.contains("z"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cycle with no exit is a trap-state hard failure`() {
|
||||
val g = graph(
|
||||
stages = mapOf("a" to stage(), "b" to stage()),
|
||||
edges = listOf("a" to "b", "b" to "a"),
|
||||
start = "a",
|
||||
)
|
||||
val result = PlanLinter.lint(g)
|
||||
assertFalse(result.passed)
|
||||
assertEquals(setOf("a", "b"), result.hardFailures.filter { it.code == "trap_state" }.map { it.stageId }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a verdict-gated review loop with an exit edge is NOT a trap`() {
|
||||
// implement <-> review, review -> done. The legitimate freestyle loop must pass.
|
||||
val g = graph(
|
||||
stages = mapOf(
|
||||
"implement" to stage(produces = listOf("patch"), prompt = "implement"),
|
||||
"review" to stage(produces = listOf("report"), needs = listOf("patch"), prompt = "review"),
|
||||
),
|
||||
edges = listOf("implement" to "review", "review" to "implement", "review" to "done"),
|
||||
start = "implement",
|
||||
)
|
||||
val result = PlanLinter.lint(g)
|
||||
assertTrue(result.hardFailures.none { it.code == "trap_state" }, "loop with exit must not trap: ${result.hardFailures}")
|
||||
assertTrue(result.passed, "hard=${result.hardFailures}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `excess fan-out is a soft finding`() {
|
||||
val g = graph(
|
||||
stages = mapOf(
|
||||
"a" to stage(prompt = "fork"),
|
||||
"b" to stage(prompt = "b"), "c" to stage(prompt = "c"),
|
||||
"d" to stage(prompt = "d"), "e" to stage(prompt = "e"),
|
||||
),
|
||||
edges = listOf("a" to "b", "a" to "c", "a" to "d", "a" to "e", "a" to "done",
|
||||
"b" to "done", "c" to "done", "d" to "done", "e" to "done"),
|
||||
start = "a",
|
||||
)
|
||||
val result = PlanLinter.lint(g)
|
||||
assertTrue(result.passed)
|
||||
val f = result.softFindings.single { it.code == "fan_out" }
|
||||
assertEquals("a", f.stageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty and duplicate briefs are soft findings`() {
|
||||
val g = graph(
|
||||
stages = mapOf(
|
||||
"a" to stage(prompt = " "),
|
||||
"b" to stage(prompt = "same work"),
|
||||
"c" to stage(prompt = "same work"),
|
||||
),
|
||||
edges = listOf("a" to "b", "b" to "c", "c" to "done"),
|
||||
start = "a",
|
||||
)
|
||||
val result = PlanLinter.lint(g)
|
||||
assertTrue(result.passed)
|
||||
assertTrue(codes(result.softFindings).containsAll(setOf("empty_brief", "duplicate_brief")))
|
||||
val dup = result.softFindings.single { it.code == "duplicate_brief" }
|
||||
assertTrue(dup.detail.contains("b") && dup.detail.contains("c"))
|
||||
assertEquals(result.softFindings.size, result.score)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user