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:
@@ -4,6 +4,9 @@ import com.correx.core.events.events.EventMetadata
|
|||||||
import com.correx.core.events.events.ExecutionPlanLockedEvent
|
import com.correx.core.events.events.ExecutionPlanLockedEvent
|
||||||
import com.correx.core.events.events.ExecutionPlanRejectedEvent
|
import com.correx.core.events.events.ExecutionPlanRejectedEvent
|
||||||
import com.correx.core.events.events.NewEvent
|
import com.correx.core.events.events.NewEvent
|
||||||
|
import com.correx.core.events.events.PlanLintCompletedEvent
|
||||||
|
import com.correx.infrastructure.workflow.PlanLintResult
|
||||||
|
import com.correx.infrastructure.workflow.PlanLinter
|
||||||
import com.correx.core.events.stores.EventStore
|
import com.correx.core.events.stores.EventStore
|
||||||
import com.correx.core.events.types.ArtifactId
|
import com.correx.core.events.types.ArtifactId
|
||||||
import com.correx.core.events.types.EventId
|
import com.correx.core.events.types.EventId
|
||||||
@@ -43,6 +46,19 @@ class FreestyleDriver(
|
|||||||
emitRejected(sessionId, "plan failed to compile: ${it.message}", "compile")
|
emitRejected(sessionId, "plan failed to compile: ${it.message}", "compile")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Deterministic lint (plan-pipeline-spec §5) before the plan is surfaced/locked: a hard
|
||||||
|
// failure (unproduced need, trap state) means the plan would fail at runtime, so reject it
|
||||||
|
// now rather than execute a broken plan. Soft findings are recorded for display only.
|
||||||
|
val lint = PlanLinter.lint(graph)
|
||||||
|
emitPlanLint(sessionId, graph.id, lint)
|
||||||
|
if (!lint.passed) {
|
||||||
|
val summary = lint.hardFailures.joinToString("; ") {
|
||||||
|
"${it.code}${it.stageId?.let { s -> " @$s" } ?: ""}: ${it.detail}"
|
||||||
|
}
|
||||||
|
log.warn("freestyle: plan failed lint for session={}: {}", sessionId.value, summary)
|
||||||
|
emitRejected(sessionId, "plan failed lint: $summary", "lint")
|
||||||
|
return
|
||||||
|
}
|
||||||
val approved = requestPlanApproval(sessionId, json)
|
val approved = requestPlanApproval(sessionId, json)
|
||||||
if (!approved) {
|
if (!approved) {
|
||||||
log.info("freestyle: execution plan rejected by operator for session={}", sessionId.value)
|
log.info("freestyle: execution plan rejected by operator for session={}", sessionId.value)
|
||||||
@@ -83,6 +99,28 @@ class FreestyleDriver(
|
|||||||
.getOrNull()
|
.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun emitPlanLint(sessionId: SessionId, candidateId: String, lint: PlanLintResult) {
|
||||||
|
eventStore.append(
|
||||||
|
NewEvent(
|
||||||
|
metadata = EventMetadata(
|
||||||
|
eventId = EventId(UUID.randomUUID().toString()),
|
||||||
|
sessionId = sessionId,
|
||||||
|
timestamp = Clock.System.now(),
|
||||||
|
schemaVersion = 1,
|
||||||
|
causationId = null,
|
||||||
|
correlationId = null,
|
||||||
|
),
|
||||||
|
payload = PlanLintCompletedEvent(
|
||||||
|
sessionId = sessionId,
|
||||||
|
candidateId = candidateId,
|
||||||
|
hardFailures = lint.hardFailures,
|
||||||
|
softFindings = lint.softFindings,
|
||||||
|
score = lint.score,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun emitRejected(sessionId: SessionId, reason: String, source: String) {
|
private suspend fun emitRejected(sessionId: SessionId, reason: String, source: String) {
|
||||||
eventStore.append(
|
eventStore.append(
|
||||||
NewEvent(
|
NewEvent(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.correx.core.artifacts.kind.DefaultArtifactKindRegistry
|
|||||||
import com.correx.core.artifacts.kind.JsonSchema
|
import com.correx.core.artifacts.kind.JsonSchema
|
||||||
import com.correx.core.events.events.ExecutionPlanLockedEvent
|
import com.correx.core.events.events.ExecutionPlanLockedEvent
|
||||||
import com.correx.core.events.events.ExecutionPlanRejectedEvent
|
import com.correx.core.events.events.ExecutionPlanRejectedEvent
|
||||||
|
import com.correx.core.events.events.PlanLintCompletedEvent
|
||||||
import com.correx.core.events.types.SessionId
|
import com.correx.core.events.types.SessionId
|
||||||
import com.correx.core.kernel.execution.WorkflowResult
|
import com.correx.core.kernel.execution.WorkflowResult
|
||||||
import com.correx.core.kernel.orchestration.OrchestrationConfig
|
import com.correx.core.kernel.orchestration.OrchestrationConfig
|
||||||
@@ -13,6 +14,7 @@ import com.correx.infrastructure.persistence.InMemoryEventStore
|
|||||||
import com.correx.infrastructure.workflow.ExecutionPlanCompiler
|
import com.correx.infrastructure.workflow.ExecutionPlanCompiler
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
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.Assertions.assertTrue
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
@@ -54,6 +56,22 @@ class FreestyleDriverTest {
|
|||||||
|
|
||||||
private val malformedPlanJson = """{ "not": "a valid plan" }"""
|
private val malformedPlanJson = """{ "not": "a valid plan" }"""
|
||||||
|
|
||||||
|
// Compiles cleanly, but "apply" needs "ghost", which no stage produces — a lint hard failure
|
||||||
|
// (the compiler does not check needs satisfaction).
|
||||||
|
private val lintFailingPlanJson = """
|
||||||
|
{
|
||||||
|
"goal": "broken plan",
|
||||||
|
"stages": [
|
||||||
|
{ "id": "analyse", "prompt": "Analyse", "produces": "patch", "needs": [], "tools": [] },
|
||||||
|
{ "id": "apply", "prompt": "Apply", "produces": "patch", "needs": ["ghost"], "tools": [] }
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{ "from": "analyse", "to": "apply", "condition": { "type": "always_true" } },
|
||||||
|
{ "from": "apply", "to": "done", "condition": { "type": "always_true" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
private fun buildRegistry(): DefaultArtifactKindRegistry =
|
private fun buildRegistry(): DefaultArtifactKindRegistry =
|
||||||
DefaultArtifactKindRegistry().also {
|
DefaultArtifactKindRegistry().also {
|
||||||
it.register(ConfigArtifactKind(id = "patch", schema = JsonSchema(type = "object"), llmEmitted = true))
|
it.register(ConfigArtifactKind(id = "patch", schema = JsonSchema(type = "object"), llmEmitted = true))
|
||||||
@@ -205,6 +223,63 @@ class FreestyleDriverTest {
|
|||||||
assertEquals("operator", rejectedEvents.single().source)
|
assertEquals("operator", rejectedEvents.single().source)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `plan failing lint is rejected with source lint and never locks or runs phase2`(): Unit = runBlocking {
|
||||||
|
val sessionId = SessionId("driver-lint-fail-session")
|
||||||
|
val eventStore = InMemoryEventStore()
|
||||||
|
val compiler = ExecutionPlanCompiler(buildRegistry())
|
||||||
|
|
||||||
|
var runPhase2Invocations = 0
|
||||||
|
var approvalRequested = false
|
||||||
|
|
||||||
|
val driver = FreestyleDriver(
|
||||||
|
eventStore = eventStore,
|
||||||
|
compiler = compiler,
|
||||||
|
planContent = { lintFailingPlanJson },
|
||||||
|
config = OrchestrationConfig(),
|
||||||
|
runPhase2 = { _, _, _ ->
|
||||||
|
runPhase2Invocations++
|
||||||
|
WorkflowResult.Completed(sessionId, com.correx.core.events.types.StageId("done"))
|
||||||
|
},
|
||||||
|
requestPlanApproval = { _, _ -> approvalRequested = true; true },
|
||||||
|
)
|
||||||
|
|
||||||
|
driver.lockAndRun(sessionId)
|
||||||
|
|
||||||
|
val payloads = eventStore.read(sessionId).map { it.payload }
|
||||||
|
assertTrue(payloads.filterIsInstance<ExecutionPlanLockedEvent>().isEmpty(), "broken plan must not lock")
|
||||||
|
assertEquals(0, runPhase2Invocations, "runPhase2 must not run on a lint failure")
|
||||||
|
assertFalse(approvalRequested, "operator must not be asked to approve a plan that already failed lint")
|
||||||
|
|
||||||
|
val lint = payloads.filterIsInstance<PlanLintCompletedEvent>().single()
|
||||||
|
assertTrue(lint.hardFailures.any { it.code == "unproduced_need" }, "expected unproduced_need: ${lint.hardFailures}")
|
||||||
|
|
||||||
|
val rejected = payloads.filterIsInstance<ExecutionPlanRejectedEvent>().single()
|
||||||
|
assertEquals("lint", rejected.source)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a clean plan records a passing PlanLintCompletedEvent before locking`(): Unit = runBlocking {
|
||||||
|
val sessionId = SessionId("driver-lint-clean-session")
|
||||||
|
val eventStore = InMemoryEventStore()
|
||||||
|
val compiler = ExecutionPlanCompiler(buildRegistry())
|
||||||
|
|
||||||
|
val driver = FreestyleDriver(
|
||||||
|
eventStore = eventStore,
|
||||||
|
compiler = compiler,
|
||||||
|
planContent = { validPlanJson },
|
||||||
|
config = OrchestrationConfig(),
|
||||||
|
runPhase2 = { sid, graph, _ -> WorkflowResult.Completed(sid, graph.start) },
|
||||||
|
)
|
||||||
|
|
||||||
|
driver.lockAndRun(sessionId)
|
||||||
|
|
||||||
|
val payloads = eventStore.read(sessionId).map { it.payload }
|
||||||
|
val lint = payloads.filterIsInstance<PlanLintCompletedEvent>().single()
|
||||||
|
assertTrue(lint.hardFailures.isEmpty(), "clean plan should have no hard failures: ${lint.hardFailures}")
|
||||||
|
assertEquals(1, payloads.filterIsInstance<ExecutionPlanLockedEvent>().size, "clean plan should lock")
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `operator approval at plan gate locks and runs phase2`(): Unit = runBlocking {
|
fun `operator approval at plan gate locks and runs phase2`(): Unit = runBlocking {
|
||||||
val sessionId = SessionId("driver-operator-approve-session")
|
val sessionId = SessionId("driver-operator-approve-session")
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.correx.core.events.events
|
||||||
|
|
||||||
|
import com.correx.core.events.types.SessionId
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One deterministic plan-lint finding. [code] is a stable machine code (e.g. "unproduced_need",
|
||||||
|
* "trap_state", "fan_out"); [stageId] is the offending stage, or null for a plan-level finding.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class PlanLintFinding(
|
||||||
|
val code: String,
|
||||||
|
val stageId: String?,
|
||||||
|
val detail: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The deterministic lint of a compiled ExecutionPlan candidate (plan-pipeline-spec §5). Pure: a
|
||||||
|
* function of the recorded plan artifact alone (no environment observation in v1 — graph-structural
|
||||||
|
* checks only), so it is recomputable on replay without re-reading anything. Recorded so the operator
|
||||||
|
* and downstream selection see why a candidate was discarded or down-ranked.
|
||||||
|
*
|
||||||
|
* A non-empty [hardFailures] means the candidate is unfit and is rejected (it must not be locked/run);
|
||||||
|
* [softFindings] are scored, not blocking. [score] is the weighted soft-finding penalty (lower is
|
||||||
|
* better) used by the future tournament ranking (§9).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("PlanLintCompleted")
|
||||||
|
data class PlanLintCompletedEvent(
|
||||||
|
val sessionId: SessionId,
|
||||||
|
val candidateId: String,
|
||||||
|
val hardFailures: List<PlanLintFinding>,
|
||||||
|
val softFindings: List<PlanLintFinding>,
|
||||||
|
val score: Int,
|
||||||
|
) : EventPayload
|
||||||
@@ -11,6 +11,7 @@ import com.correx.core.events.events.ArtifactValidatingEvent
|
|||||||
import com.correx.core.events.events.BriefEchoMismatchEvent
|
import com.correx.core.events.events.BriefEchoMismatchEvent
|
||||||
import com.correx.core.events.events.BriefGroundingCheckedEvent
|
import com.correx.core.events.events.BriefGroundingCheckedEvent
|
||||||
import com.correx.core.events.events.StaticAnalysisCompletedEvent
|
import com.correx.core.events.events.StaticAnalysisCompletedEvent
|
||||||
|
import com.correx.core.events.events.PlanLintCompletedEvent
|
||||||
import com.correx.core.events.events.ChatSessionStartedEvent
|
import com.correx.core.events.events.ChatSessionStartedEvent
|
||||||
import com.correx.core.events.events.ClarificationAnsweredEvent
|
import com.correx.core.events.events.ClarificationAnsweredEvent
|
||||||
import com.correx.core.events.events.ClarificationRequestedEvent
|
import com.correx.core.events.events.ClarificationRequestedEvent
|
||||||
@@ -112,6 +113,7 @@ val eventModule = SerializersModule {
|
|||||||
subclass(BriefGroundingCheckedEvent::class)
|
subclass(BriefGroundingCheckedEvent::class)
|
||||||
subclass(BriefEchoMismatchEvent::class)
|
subclass(BriefEchoMismatchEvent::class)
|
||||||
subclass(StaticAnalysisCompletedEvent::class)
|
subclass(StaticAnalysisCompletedEvent::class)
|
||||||
|
subclass(PlanLintCompletedEvent::class)
|
||||||
subclass(RiskAssessedEvent::class)
|
subclass(RiskAssessedEvent::class)
|
||||||
subclass(ChatSessionStartedEvent::class)
|
subclass(ChatSessionStartedEvent::class)
|
||||||
subclass(ChatTurnEvent::class)
|
subclass(ChatTurnEvent::class)
|
||||||
|
|||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
package com.correx.core.events.serialization
|
||||||
|
|
||||||
|
import com.correx.core.events.events.EventPayload
|
||||||
|
import com.correx.core.events.events.PlanLintCompletedEvent
|
||||||
|
import com.correx.core.events.events.PlanLintFinding
|
||||||
|
import com.correx.core.events.types.SessionId
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class PlanLintCompletedEventSerializationTest {
|
||||||
|
|
||||||
|
private val sample = PlanLintCompletedEvent(
|
||||||
|
sessionId = SessionId("sess-1"),
|
||||||
|
candidateId = "freestyle-sess-1",
|
||||||
|
hardFailures = listOf(
|
||||||
|
PlanLintFinding(code = "unproduced_need", stageId = "implement", detail = "needs 'design', produced by no stage"),
|
||||||
|
PlanLintFinding(code = "trap_state", stageId = "loop", detail = "no path to 'done'"),
|
||||||
|
),
|
||||||
|
softFindings = listOf(
|
||||||
|
PlanLintFinding(code = "fan_out", stageId = "analyze", detail = "5 outgoing edges"),
|
||||||
|
),
|
||||||
|
score = 3,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `round-trips as polymorphic EventPayload`() {
|
||||||
|
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
|
||||||
|
assertTrue(encoded.contains("\"type\":\"PlanLintCompleted\""), "SerialName must be present: $encoded")
|
||||||
|
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
|
||||||
|
assertEquals(sample, decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `decodes hand-written PlanLintCompleted JSON`() {
|
||||||
|
val json = """
|
||||||
|
{"type":"PlanLintCompleted","sessionId":"s","candidateId":"c1",
|
||||||
|
"hardFailures":[{"code":"unproduced_need","stageId":"x","detail":"needs y"}],
|
||||||
|
"softFindings":[],"score":0}
|
||||||
|
""".trimIndent()
|
||||||
|
val decoded = eventJson.decodeFromString(EventPayload.serializer(), json)
|
||||||
|
assertTrue(decoded is PlanLintCompletedEvent)
|
||||||
|
val event = decoded as PlanLintCompletedEvent
|
||||||
|
assertEquals("unproduced_need", event.hardFailures.single().code)
|
||||||
|
assertEquals("x", event.hardFailures.single().stageId)
|
||||||
|
assertTrue(event.softFindings.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `plan-level finding allows null stageId`() {
|
||||||
|
val withNull = sample.copy(
|
||||||
|
hardFailures = listOf(PlanLintFinding(code = "trap_state", stageId = null, detail = "plan-level")),
|
||||||
|
)
|
||||||
|
val encoded = eventJson.encodeToString(EventPayload.serializer(), withNull)
|
||||||
|
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
|
||||||
|
assertEquals(withNull, decoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
+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