epic-12: after epic audit and init commit
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
plugins {
|
||||
id 'java-library'
|
||||
id 'org.jetbrains.kotlin.jvm'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:events"))
|
||||
implementation(project(":core:inference"))
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.correx.core.transitions.analysis
|
||||
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.transitions.analysis.canonicalization.CycleCanonicalizer.canonicalize
|
||||
import com.correx.core.transitions.graph.WorkflowGraph
|
||||
import com.correx.core.transitions.state.CycleDfs
|
||||
import com.correx.core.transitions.state.VisitState
|
||||
|
||||
internal class CycleExtractor(
|
||||
private val adjacencyBuilder: DeterministicAdjacencyBuilder,
|
||||
private val dfs: CycleDfs,
|
||||
) {
|
||||
|
||||
fun extract(graph: WorkflowGraph): List<DetectedCycle> {
|
||||
|
||||
val adjacency = adjacencyBuilder.build(graph)
|
||||
|
||||
val state = mutableMapOf<StageId, VisitState>()
|
||||
val path = mutableListOf<StageId>()
|
||||
|
||||
val cycles = mutableListOf<DetectedCycle>()
|
||||
|
||||
val sortedNodes = graph.stageIds
|
||||
.sortedBy { it.value }
|
||||
|
||||
for (node in sortedNodes) {
|
||||
if (state[node] == null) {
|
||||
dfs.dfs(node, adjacency, state, path, cycles)
|
||||
}
|
||||
}
|
||||
|
||||
return canonicalize(cycles)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.correx.core.transitions.analysis
|
||||
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.transitions.graph.TransitionEdge
|
||||
|
||||
data class DetectedCycle(
|
||||
val nodes: List<StageId>,
|
||||
val edges: List<TransitionEdge>
|
||||
)
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.correx.core.transitions.analysis
|
||||
|
||||
import com.correx.core.transitions.graph.TransitionEdge
|
||||
import com.correx.core.transitions.graph.WorkflowGraph
|
||||
import com.correx.core.events.types.StageId
|
||||
|
||||
internal class DeterministicAdjacencyBuilder {
|
||||
fun build(graph: WorkflowGraph): Map<StageId, List<TransitionEdge>> {
|
||||
|
||||
val grouped = graph.transitions
|
||||
.groupBy { it.from }
|
||||
|
||||
return grouped.mapValues { (_, edges) ->
|
||||
edges.sortedWith(
|
||||
compareBy<TransitionEdge> { it.from.value }
|
||||
.thenBy { it.id.value }
|
||||
.thenBy { it.to.value }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.correx.core.transitions.analysis.canonicalization
|
||||
|
||||
import com.correx.core.transitions.analysis.DetectedCycle
|
||||
|
||||
internal object CycleCanonicalizer {
|
||||
fun canonicalize(
|
||||
cycles: List<DetectedCycle>
|
||||
): List<DetectedCycle> {
|
||||
val normalized = cycles.map { cycle ->
|
||||
|
||||
if (cycle.nodes.isEmpty()) return@map cycle
|
||||
|
||||
val open = cycle.nodes.dropLast(1)
|
||||
|
||||
val minIndex = open
|
||||
.withIndex()
|
||||
.minBy { it.value.value }
|
||||
.index
|
||||
|
||||
val rotatedNodes = open.drop(minIndex) + open.take(minIndex)
|
||||
val rotatedEdges = cycle.edges.drop(minIndex) + cycle.edges.take(minIndex)
|
||||
|
||||
val closed = rotatedNodes + rotatedNodes.first()
|
||||
|
||||
DetectedCycle(closed, rotatedEdges)
|
||||
}
|
||||
|
||||
return normalized
|
||||
.distinctBy { cycle ->
|
||||
val nodeKey = cycle.nodes.joinToString("->") { it.value }
|
||||
val edgeKey = cycle.edges.joinToString("->") { it.id.value }
|
||||
"$nodeKey|$edgeKey"
|
||||
}
|
||||
.sortedBy { it.nodes.first().value }
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.correx.core.transitions.evaluation
|
||||
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
|
||||
data class EvaluationContext(
|
||||
val sessionId: SessionId,
|
||||
val currentStage: StageId,
|
||||
val variables: Map<String, String>
|
||||
)
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.correx.core.transitions.evaluation
|
||||
|
||||
import com.correx.core.transitions.graph.TransitionCondition
|
||||
|
||||
interface TransitionConditionEvaluator {
|
||||
fun evaluate(
|
||||
condition: TransitionCondition,
|
||||
context: EvaluationContext
|
||||
): Boolean
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.correx.core.transitions.execution
|
||||
|
||||
import com.correx.core.transitions.evaluation.EvaluationContext
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.TransitionId
|
||||
|
||||
data class StageExecutionRequest(
|
||||
val sessionId: String,
|
||||
val from: StageId,
|
||||
val to: StageId,
|
||||
val transitionId: TransitionId,
|
||||
val context: EvaluationContext
|
||||
)
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.correx.core.transitions.execution
|
||||
|
||||
sealed interface StageExecutionResult {
|
||||
data class Success(
|
||||
val producedArtifacts: List<String>
|
||||
) : StageExecutionResult
|
||||
|
||||
data class Failure(
|
||||
val reason: String,
|
||||
val retryable: Boolean,
|
||||
) : StageExecutionResult
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.correx.core.transitions.execution
|
||||
|
||||
interface StageExecutor {
|
||||
fun execute(
|
||||
request: StageExecutionRequest
|
||||
): StageExecutionResult
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.correx.core.transitions.graph
|
||||
|
||||
import com.correx.core.events.types.StageId
|
||||
|
||||
internal fun WorkflowGraph.sortedStages(): List<StageId> =
|
||||
stages.keys.sortedBy { it.value }
|
||||
|
||||
internal fun WorkflowGraph.sortedTransitions(): List<TransitionEdge> =
|
||||
transitions.sortedWith(
|
||||
compareBy(
|
||||
{ it.from.value },
|
||||
{ it.id.value },
|
||||
{ it.to.value }
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.correx.core.transitions.graph
|
||||
|
||||
import com.correx.core.inference.GenerationConfig
|
||||
import com.correx.core.inference.ModelCapability
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class StageConfig(
|
||||
val requiredCapabilities: Set<ModelCapability> = emptySet(),
|
||||
val tokenBudget: Int = 4096,
|
||||
val generationConfig: GenerationConfig = GenerationConfig(
|
||||
temperature = 0.7,
|
||||
topP = 1.0,
|
||||
maxTokens = 2048,
|
||||
),
|
||||
val allowedTools: Set<String> = emptySet(),
|
||||
val maxRetries: Int = 3,
|
||||
)
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.correx.core.transitions.graph
|
||||
|
||||
import com.correx.core.transitions.evaluation.EvaluationContext
|
||||
|
||||
fun interface TransitionCondition {
|
||||
fun evaluate(context: EvaluationContext): Boolean
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.correx.core.transitions.graph
|
||||
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.TransitionId
|
||||
|
||||
data class TransitionEdge(
|
||||
val id: TransitionId,
|
||||
val from: StageId,
|
||||
val to: StageId,
|
||||
val condition: TransitionCondition
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.correx.core.transitions.graph
|
||||
|
||||
import com.correx.core.events.types.StageId
|
||||
|
||||
data class WorkflowGraph(
|
||||
val stages: Map<StageId, StageConfig>,
|
||||
val transitions: Set<TransitionEdge>,
|
||||
val start: StageId,
|
||||
) {
|
||||
val stageIds: Set<StageId> get() = stages.keys
|
||||
init {
|
||||
require(start in stages) { "start stage must exist in stages" }
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package com.correx.core.transitions.mapper
|
||||
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.StageCompletedEvent
|
||||
import com.correx.core.events.events.StageFailedEvent
|
||||
import com.correx.core.events.events.TransitionExecutedEvent
|
||||
import com.correx.core.transitions.execution.StageExecutionRequest
|
||||
import com.correx.core.transitions.execution.StageExecutionResult
|
||||
import com.correx.core.events.types.SessionId
|
||||
|
||||
class DefaultStageExecutionEventMapper : StageExecutionEventMapper {
|
||||
|
||||
override fun toEvents(
|
||||
request: StageExecutionRequest,
|
||||
result: StageExecutionResult
|
||||
): List<EventPayload> {
|
||||
|
||||
val baseTransition = TransitionExecutedEvent(
|
||||
sessionId = SessionId(request.sessionId),
|
||||
from = request.from,
|
||||
to = request.to,
|
||||
transitionId = request.transitionId
|
||||
)
|
||||
|
||||
return when (result) {
|
||||
|
||||
is StageExecutionResult.Success -> listOf(
|
||||
baseTransition,
|
||||
StageCompletedEvent(
|
||||
sessionId = SessionId(request.sessionId),
|
||||
stageId = request.to,
|
||||
transitionId = request.transitionId
|
||||
)
|
||||
)
|
||||
|
||||
is StageExecutionResult.Failure -> listOf(
|
||||
baseTransition,
|
||||
StageFailedEvent(
|
||||
sessionId = SessionId(request.sessionId),
|
||||
stageId = request.to,
|
||||
transitionId = request.transitionId,
|
||||
reason = result.reason
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.correx.core.transitions.mapper
|
||||
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.transitions.execution.StageExecutionRequest
|
||||
import com.correx.core.transitions.execution.StageExecutionResult
|
||||
|
||||
interface StageExecutionEventMapper {
|
||||
fun toEvents(
|
||||
request: StageExecutionRequest,
|
||||
result: StageExecutionResult
|
||||
): List<EventPayload>
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.correx.core.transitions.policy
|
||||
|
||||
sealed interface CyclePolicy {
|
||||
data class Retry(
|
||||
val maxAttempts: Int
|
||||
) : CyclePolicy
|
||||
|
||||
data class Refinement(
|
||||
val maxIterations: Int
|
||||
) : CyclePolicy
|
||||
|
||||
data class Approval(
|
||||
val timeoutMs: Long
|
||||
) : CyclePolicy
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package com.correx.core.transitions.policy
|
||||
|
||||
data class CyclePolicyBinding(
|
||||
val cycle: CycleSignature,
|
||||
val policy: CyclePolicy
|
||||
)
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.correx.core.transitions.policy
|
||||
|
||||
class CyclePolicyResolver(
|
||||
private val bindings: Set<CyclePolicyBinding>
|
||||
) {
|
||||
|
||||
fun resolve(signature: CycleSignature): CyclePolicy? {
|
||||
return bindings
|
||||
.firstOrNull { it.cycle == signature }
|
||||
?.policy
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.correx.core.transitions.policy
|
||||
|
||||
import com.correx.core.events.types.StageId
|
||||
import java.util.SortedSet
|
||||
|
||||
data class CycleSignature(
|
||||
val nodes: SortedSet<StageId>,
|
||||
val edges: SortedSet<Pair<StageId, StageId>>,
|
||||
)
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.correx.core.transitions.policy
|
||||
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.transitions.graph.TransitionEdge
|
||||
import java.util.TreeSet
|
||||
|
||||
object CycleSignatureFactory {
|
||||
fun from(nodes: List<StageId>, edges: List<TransitionEdge>): CycleSignature {
|
||||
val nodeSet = TreeSet<StageId>(compareBy { it.value })
|
||||
nodeSet.addAll(nodes)
|
||||
|
||||
val edgeSet = TreeSet<Pair<StageId, StageId>>(
|
||||
compareBy({ it.first.value }, { it.second.value })
|
||||
)
|
||||
edges.forEach { edgeSet.add(it.from to it.to) }
|
||||
|
||||
return CycleSignature(nodes = nodeSet, edges = edgeSet)
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.correx.core.transitions.policy
|
||||
|
||||
class PolicyValidation {
|
||||
fun validate(
|
||||
bindings: Set<CyclePolicyBinding>,
|
||||
knownCycles: Set<CycleSignature>
|
||||
): List<String> {
|
||||
|
||||
val errors = mutableListOf<String>()
|
||||
|
||||
val bound = bindings.map { it.cycle }.toSet()
|
||||
|
||||
val unbound = knownCycles - bound
|
||||
|
||||
if (unbound.isNotEmpty()) {
|
||||
errors += unbound.map {
|
||||
"Cycle $it has no policy binding"
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.correx.core.transitions.resolution
|
||||
|
||||
import com.correx.core.transitions.evaluation.EvaluationContext
|
||||
import com.correx.core.transitions.evaluation.TransitionConditionEvaluator
|
||||
import com.correx.core.transitions.graph.WorkflowGraph
|
||||
|
||||
class DefaultTransitionResolver(
|
||||
private val evaluator: TransitionConditionEvaluator
|
||||
) : TransitionResolver {
|
||||
|
||||
@Suppress("ReturnCount")
|
||||
override fun resolve(
|
||||
graph: WorkflowGraph,
|
||||
context: EvaluationContext
|
||||
): TransitionDecision {
|
||||
val outgoing = graph.transitions
|
||||
.asSequence()
|
||||
.filter { it.from == context.currentStage }
|
||||
.sortedWith(TransitionOrdering.comparator)
|
||||
.toList()
|
||||
|
||||
if (outgoing.isEmpty()) return TransitionDecision.NoMatch
|
||||
|
||||
for (edge in outgoing) {
|
||||
val result = evaluator.evaluate(edge.condition, context)
|
||||
if (result) {
|
||||
return TransitionDecision.Move(
|
||||
transitionId = edge.id,
|
||||
to = edge.to
|
||||
)
|
||||
}
|
||||
}
|
||||
return TransitionDecision.Stay
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.correx.core.transitions.resolution
|
||||
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.TransitionId
|
||||
|
||||
sealed interface TransitionDecision {
|
||||
|
||||
data class Move(
|
||||
val transitionId: TransitionId,
|
||||
val to: StageId
|
||||
) : TransitionDecision
|
||||
|
||||
data object Stay : TransitionDecision
|
||||
|
||||
data class Blocked(
|
||||
val reason: String
|
||||
) : TransitionDecision
|
||||
|
||||
data object NoMatch : TransitionDecision
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.correx.core.transitions.resolution
|
||||
|
||||
import com.correx.core.transitions.graph.TransitionEdge
|
||||
|
||||
object TransitionOrdering {
|
||||
val comparator: Comparator<TransitionEdge> =
|
||||
compareBy<TransitionEdge> { it.from.value }
|
||||
.thenBy { it.id.value }
|
||||
.thenBy { it.to.value }
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.correx.core.transitions.resolution
|
||||
|
||||
import com.correx.core.transitions.evaluation.EvaluationContext
|
||||
import com.correx.core.transitions.graph.WorkflowGraph
|
||||
|
||||
interface TransitionResolver {
|
||||
fun resolve(
|
||||
graph: WorkflowGraph,
|
||||
context: EvaluationContext
|
||||
): TransitionDecision
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.correx.core.transitions.state
|
||||
|
||||
import com.correx.core.transitions.analysis.DetectedCycle
|
||||
import com.correx.core.transitions.graph.TransitionEdge
|
||||
import com.correx.core.events.types.StageId
|
||||
|
||||
internal class CycleDfs {
|
||||
fun dfs(
|
||||
node: StageId,
|
||||
adjacency: Map<StageId, List<TransitionEdge>>,
|
||||
state: MutableMap<StageId, VisitState>,
|
||||
path: MutableList<StageId>,
|
||||
cycles: MutableList<DetectedCycle>
|
||||
) {
|
||||
state[node] = VisitState.VISITING
|
||||
path.add(node)
|
||||
|
||||
val edges = adjacency[node].orEmpty()
|
||||
|
||||
for (edge in edges) {
|
||||
val next = edge.to
|
||||
when (state[next]) {
|
||||
VisitState.VISITING -> {
|
||||
val cycleStart = path.indexOf(next)
|
||||
if (cycleStart >= 0) {
|
||||
val cycleNodes = path.subList(cycleStart, path.size) + next
|
||||
val cycleEdges = cycleNodes.zipWithNext { from, to ->
|
||||
adjacency[from]?.firstOrNull { it.to == to }
|
||||
}.filterNotNull()
|
||||
cycles.add(DetectedCycle(nodes = cycleNodes, edges = cycleEdges))
|
||||
}
|
||||
}
|
||||
|
||||
VisitState.VISITED -> Unit
|
||||
null -> dfs(next, adjacency, state, path, cycles)
|
||||
}
|
||||
}
|
||||
path.removeAt(path.lastIndex)
|
||||
state[node] = VisitState.VISITED
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.correx.core.transitions.state
|
||||
|
||||
internal enum class VisitState {
|
||||
VISITING,
|
||||
VISITED
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package com.correx.core.transitions.analysis.canonicalization
|
||||
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.TransitionId
|
||||
import com.correx.core.transitions.analysis.DetectedCycle
|
||||
import com.correx.core.transitions.graph.TransitionEdge
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class CycleCanonicalizerTest {
|
||||
|
||||
private fun edge(id: String, from: String, to: String) = TransitionEdge(
|
||||
id = TransitionId(id),
|
||||
from = StageId(from),
|
||||
to = StageId(to),
|
||||
condition = { true }
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `same node set but different edges produce two entries`() {
|
||||
// A -> B -> A with edge t1, and A -> B -> A with edge t2 — different edges
|
||||
val cycleWithT1 = DetectedCycle(
|
||||
nodes = listOf(StageId("A"), StageId("B"), StageId("A")),
|
||||
edges = listOf(edge("t1", "A", "B"), edge("t1b", "B", "A"))
|
||||
)
|
||||
val cycleWithT2 = DetectedCycle(
|
||||
nodes = listOf(StageId("A"), StageId("B"), StageId("A")),
|
||||
edges = listOf(edge("t2", "A", "B"), edge("t2b", "B", "A"))
|
||||
)
|
||||
|
||||
val result = CycleCanonicalizer.canonicalize(listOf(cycleWithT1, cycleWithT2))
|
||||
|
||||
assertEquals(2, result.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two rotations of the same cycle dedup to one entry`() {
|
||||
// Cycle A -> B -> C -> A, represented starting at B or starting at A
|
||||
val eAB = edge("t1", "A", "B")
|
||||
val eBC = edge("t2", "B", "C")
|
||||
val eCA = edge("t3", "C", "A")
|
||||
|
||||
val startAtA = DetectedCycle(
|
||||
nodes = listOf(StageId("A"), StageId("B"), StageId("C"), StageId("A")),
|
||||
edges = listOf(eAB, eBC, eCA)
|
||||
)
|
||||
val startAtB = DetectedCycle(
|
||||
nodes = listOf(StageId("B"), StageId("C"), StageId("A"), StageId("B")),
|
||||
edges = listOf(eBC, eCA, eAB)
|
||||
)
|
||||
|
||||
val result = CycleCanonicalizer.canonicalize(listOf(startAtA, startAtB))
|
||||
|
||||
assertEquals(1, result.size)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user