epic-12: after epic audit and init commit

This commit is contained in:
2026-05-16 11:42:00 +04:00
commit c77277af0b
461 changed files with 28958 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
plugins {
id 'java-library'
id 'org.jetbrains.kotlin.jvm'
id 'org.jetbrains.kotlin.plugin.serialization'
}
dependencies {
testImplementation(project(":core:events"))
testImplementation(project(":core:sessions"))
testImplementation(project(":core:transitions"))
testImplementation(project(":core:validation"))
testImplementation(project(":core:approvals"))
testImplementation(project(":infrastructure:persistence"))
testImplementation(project(":testing:fixtures"))
}
@@ -0,0 +1,3 @@
package com.correx.testing.approvals
object Module
@@ -0,0 +1,88 @@
import com.correx.core.approvals.Tier
import com.correx.core.approvals.domain.DefaultApprovalEngine
import com.correx.core.approvals.model.ApprovalContext
import com.correx.core.approvals.model.ApprovalGrant
import com.correx.core.approvals.model.ApprovalScopeIdentity
import com.correx.core.approvals.ApprovalStatus
import com.correx.core.approvals.GrantScope
import com.correx.core.events.types.GrantId
import com.correx.core.events.types.ProjectId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.sessions.ApprovalMode
import com.correx.testing.fixtures.request
import kotlinx.datetime.Instant
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class ApprovalEngineEdgeCasesTest {
private val engine = DefaultApprovalEngine()
private val now = Instant.parse("2026-01-01T00:00:01Z")
private fun context(mode: ApprovalMode) = ApprovalContext(
ApprovalScopeIdentity(SessionId("s1"), StageId("st1"), ProjectId("p1")),
mode
)
@Test
fun `grants from different scopes apply only to matching identity`() {
val sessionGrant = ApprovalGrant(
id = GrantId("g1"),
scope = GrantScope.SESSION,
permittedTiers = setOf(Tier.T3),
reason = "",
timestamp = Instant.parse("2026-01-01T00:00:00Z")
)
val stageGrant = ApprovalGrant(
id = GrantId("g2"),
scope = GrantScope.STAGE(StageId("st2")),
permittedTiers = setOf(Tier.T4),
reason = "",
timestamp = Instant.parse("2026-01-01T00:00:00Z")
)
val decision = engine.evaluate(
request("r1", Tier.T4),
context(ApprovalMode.DENY),
listOf(sessionGrant, stageGrant),
now
)
assertEquals(
ApprovalStatus.PENDING, decision.state,
"Stage grant for different stage should not apply"
)
}
@Test
fun `expired grant is ignored`() {
val past = Instant.parse("2025-12-31T23:59:59Z")
val expiredGrant = ApprovalGrant(
id = GrantId("g1"),
scope = GrantScope.SESSION,
permittedTiers = setOf(Tier.T2),
reason = "",
timestamp = past,
expiresAt = past // expired before 'now'
)
val decision = engine.evaluate(
request("r1", Tier.T2),
context(ApprovalMode.DENY),
listOf(expiredGrant),
now
)
assertEquals(ApprovalStatus.PENDING, decision.state)
}
@Test
fun `timestamp passed to evaluate is used as resolution timestamp`() {
val myNow = Instant.parse("2026-07-01T12:00:00Z")
val decision = engine.evaluate(
request("r1", Tier.T0),
context(ApprovalMode.YOLO),
emptyList(),
myNow
)
assertEquals(myNow, decision.resolutionTimestamp)
}
}
@@ -0,0 +1,50 @@
import com.correx.core.validation.approval.ApprovalTrigger
import com.correx.core.validation.model.ValidationIssue
import com.correx.core.validation.model.ValidationReport
import com.correx.core.validation.model.ValidationSection
import com.correx.core.validation.model.ValidationSeverity
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
class ApprovalTriggerTest {
private val trigger = ApprovalTrigger()
@Test
fun `should trigger approval on error`() {
val report = ValidationReport(
sections = listOf(
ValidationSection(
name = "graph",
issues = listOf(
ValidationIssue(
code = "GRAPH_DANGLING_TRANSITION",
message = "error",
severity = ValidationSeverity.ERROR,
),
),
),
),
)
val result = trigger.evaluate(report)
assertNotNull(result)
}
@Test
fun `should not trigger approval on clean report`() {
val report = ValidationReport(
sections = listOf(
ValidationSection("graph", emptyList()),
),
)
val result = trigger.evaluate(report)
assertNull(result)
}
}
@@ -0,0 +1,92 @@
import com.correx.core.approvals.Tier
import com.correx.core.approvals.domain.DefaultApprovalEngine
import com.correx.core.approvals.model.ApprovalContext
import com.correx.core.approvals.model.ApprovalGrant
import com.correx.core.approvals.ApprovalOutcome
import com.correx.core.approvals.model.ApprovalScopeIdentity
import com.correx.core.approvals.ApprovalStatus
import com.correx.core.approvals.GrantScope
import com.correx.core.events.types.GrantId
import com.correx.core.events.types.SessionId
import com.correx.core.sessions.ApprovalMode
import com.correx.testing.fixtures.request
import kotlinx.datetime.Instant
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
class GrantSemanticsTest {
private val engine = DefaultApprovalEngine()
@Test
fun `grant permits exact tier - auto_approved`() {
val req = request("r1", Tier.T2)
val grant = ApprovalGrant(
id = GrantId("g1"),
scope = GrantScope.SESSION,
permittedTiers = setOf(Tier.T2),
reason = "test",
timestamp = Instant.parse("2026-01-01T00:00:00Z")
)
val ctx = ApprovalContext(
ApprovalScopeIdentity(SessionId("s1"), null, null),
ApprovalMode.DENY
)
val now = Instant.parse("2026-01-01T00:00:01Z")
val decision = engine.evaluate(req, ctx, listOf(grant), now)
assertEquals(ApprovalOutcome.AUTO_APPROVED, decision.outcome)
assertEquals(ApprovalStatus.COMPLETED, decision.state)
}
@Test
fun `grant does not permit other tiers`() {
val req = request("r1", Tier.T3)
val grant = ApprovalGrant(
id = GrantId("g1"),
scope = GrantScope.SESSION,
permittedTiers = setOf(Tier.T2),
reason = "test",
timestamp = Instant.parse("2026-01-01T00:00:00Z")
)
val ctx = ApprovalContext(
ApprovalScopeIdentity(SessionId("s1"), null, null),
ApprovalMode.DENY
)
val now = Instant.parse("2026-01-01T00:00:01Z")
val decision = engine.evaluate(req, ctx, listOf(grant), now)
assertEquals(ApprovalStatus.PENDING, decision.state)
assertNull(decision.outcome)
}
@Test
fun `multiple grants - any match is enough`() {
val req = request("r1", Tier.T4)
val grants = listOf(
ApprovalGrant(
GrantId("g1"),
GrantScope.SESSION,
setOf(Tier.T3),
"",
Instant.parse("2026-01-01T00:00:00Z")
),
ApprovalGrant(
GrantId("g2"),
GrantScope.SESSION,
setOf(Tier.T4),
"",
Instant.parse("2026-01-01T00:00:00Z")
)
)
val ctx = ApprovalContext(
ApprovalScopeIdentity(SessionId("s1"), null, null),
ApprovalMode.DENY
)
val now = Instant.parse("2026-01-01T00:00:01Z")
val decision = engine.evaluate(req, ctx, grants, now)
assertEquals(ApprovalOutcome.AUTO_APPROVED, decision.outcome)
}
}
@@ -0,0 +1,88 @@
import com.correx.core.approvals.Tier
import com.correx.core.approvals.domain.DefaultApprovalEngine
import com.correx.core.approvals.model.ApprovalContext
import com.correx.core.approvals.ApprovalOutcome
import com.correx.core.approvals.model.ApprovalScopeIdentity
import com.correx.core.approvals.ApprovalStatus
import com.correx.core.events.types.SessionId
import com.correx.core.sessions.ApprovalMode
import com.correx.testing.fixtures.request
import kotlinx.datetime.Instant
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class ModeApprovalTest {
private val engine = DefaultApprovalEngine()
private fun contextForMode(mode: ApprovalMode) = ApprovalContext(
ApprovalScopeIdentity(SessionId("s1"), null, null),
mode
)
private val now = Instant.parse("2026-01-01T00:00:01Z")
@Test
fun `DENY mode allows only T0`() {
val ctx = contextForMode(ApprovalMode.DENY)
assertEquals(
ApprovalOutcome.AUTO_APPROVED,
engine.evaluate(request("r1", Tier.T0), ctx, emptyList(), now).outcome
)
assertEquals(
ApprovalStatus.PENDING,
engine.evaluate(request("r2", Tier.T1), ctx, emptyList(), now).state
)
assertEquals(
ApprovalStatus.PENDING,
engine.evaluate(request("r3", Tier.T4), ctx, emptyList(), now).state
)
}
@Test
fun `PROMPT mode allows T0-T1`() {
val ctx = contextForMode(ApprovalMode.PROMPT)
assertEquals(
ApprovalOutcome.AUTO_APPROVED,
engine.evaluate(request("r1", Tier.T0), ctx, emptyList(), now).outcome
)
assertEquals(
ApprovalOutcome.AUTO_APPROVED,
engine.evaluate(request("r2", Tier.T1), ctx, emptyList(), now).outcome
)
assertEquals(
ApprovalStatus.PENDING,
engine.evaluate(request("r3", Tier.T2), ctx, emptyList(), now).state
)
}
@Test
fun `AUTO mode allows T0-T2`() {
val ctx = contextForMode(ApprovalMode.AUTO)
assertEquals(
ApprovalOutcome.AUTO_APPROVED,
engine.evaluate(request("r1", Tier.T0), ctx, emptyList(), now).outcome
)
assertEquals(
ApprovalOutcome.AUTO_APPROVED,
engine.evaluate(request("r2", Tier.T2), ctx, emptyList(), now).outcome
)
assertEquals(
ApprovalStatus.PENDING,
engine.evaluate(request("r3", Tier.T3), ctx, emptyList(), now).state
)
}
@Test
fun `YOLO mode allows all tiers, including T4`() {
val ctx = contextForMode(ApprovalMode.YOLO)
assertEquals(
ApprovalOutcome.AUTO_APPROVED,
engine.evaluate(request("r1", Tier.T0), ctx, emptyList(), now).outcome
)
assertEquals(
ApprovalOutcome.AUTO_APPROVED,
engine.evaluate(request("r2", Tier.T4), ctx, emptyList(), now).outcome
)
}
}
@@ -0,0 +1,58 @@
import com.correx.core.approvals.Tier
import com.correx.core.approvals.domain.DefaultApprovalEngine
import com.correx.core.approvals.model.ApprovalContext
import com.correx.core.approvals.model.ApprovalGrant
import com.correx.core.approvals.model.ApprovalScopeIdentity
import com.correx.core.approvals.GrantScope
import com.correx.core.events.types.GrantId
import com.correx.core.events.types.SessionId
import com.correx.core.sessions.ApprovalMode
import com.correx.testing.fixtures.request
import kotlinx.datetime.Instant
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertDoesNotThrow
class NoExecutionCouplingTest {
@Test
fun `engine does not mutate grants or context`() {
val engine = DefaultApprovalEngine()
val grants = mutableListOf(
ApprovalGrant(
GrantId("g1"),
GrantScope.SESSION,
setOf(Tier.T2),
"",
Instant.parse("2026-01-01T00:00:00Z")
)
)
val originalGrants = grants.toList()
val ctx = ApprovalContext(
ApprovalScopeIdentity(SessionId("s1"), null, null),
ApprovalMode.DENY
)
val now = Instant.parse("2026-01-01T00:00:01Z")
engine.evaluate(request("r1", Tier.T2), ctx, grants, now)
assertEquals(originalGrants, grants, "Grants list must not be mutated")
assertEquals(ApprovalMode.DENY, ctx.mode, "Context must not be mutated")
}
@Test
fun `engine does not throw for valid input`() {
val engine = DefaultApprovalEngine()
assertDoesNotThrow {
engine.evaluate(
request("r1", Tier.T0),
ApprovalContext(
ApprovalScopeIdentity(SessionId("s1"), null, null),
ApprovalMode.YOLO
),
emptyList(),
Instant.parse("2026-01-01T00:00:01Z")
)
}
}
}
@@ -0,0 +1,55 @@
import com.correx.core.approvals.Tier
import com.correx.core.approvals.domain.DefaultApprovalEngine
import com.correx.core.approvals.model.ApprovalContext
import com.correx.core.approvals.model.ApprovalGrant
import com.correx.core.approvals.model.ApprovalScopeIdentity
import com.correx.core.approvals.GrantScope
import com.correx.core.sessions.ApprovalMode
import com.correx.core.events.types.GrantId
import com.correx.core.events.types.SessionId
import com.correx.testing.fixtures.request
import kotlinx.datetime.Instant
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Test
class TierImmutabilityTest {
private val engine = DefaultApprovalEngine()
@Test
fun `engine never modifies tier - always returns original`() {
val tiers = listOf(Tier.T0, Tier.T1, Tier.T2, Tier.T3, Tier.T4)
val ctx = ApprovalContext(
ApprovalScopeIdentity(SessionId("s1"), null, null),
ApprovalMode.YOLO
)
val now = Instant.parse("2026-01-01T00:00:01Z")
tiers.forEach { tier ->
val req = request("r1", tier)
val decision = engine.evaluate(req, ctx, emptyList(), now)
Assertions.assertEquals(tier, decision.tier, "Tier must remain unchanged")
}
}
@Test
fun `grant does not change tier in decision`() {
val tier = Tier.T3
val req = request("r1", tier)
val grant = ApprovalGrant(
id = GrantId("g1"),
scope = GrantScope.SESSION,
permittedTiers = setOf(tier),
reason = "test",
timestamp = Instant.parse("2026-01-01T00:00:00Z")
)
val ctx = ApprovalContext(
ApprovalScopeIdentity(SessionId("s1"), null, null),
ApprovalMode.DENY
)
val now = Instant.parse("2026-01-01T00:00:01Z")
val decision = engine.evaluate(req, ctx, listOf(grant), now)
Assertions.assertEquals(tier, decision.tier)
}
}
+3
View File
@@ -0,0 +1,3 @@
subprojects {
group = "com.correx.testing"
}
+23
View File
@@ -0,0 +1,23 @@
plugins {
id 'java-library'
id 'org.jetbrains.kotlin.jvm'
id 'java-test-fixtures'
}
dependencies {
implementation(project(":core:events"))
testImplementation(project(":core:transitions"))
testImplementation(project(":core:sessions"))
testImplementation(project(":core:validation"))
testImplementation(project(":core:approvals"))
testImplementation(project(":core:artifacts"))
testImplementation(project(":core:kernel"))
testImplementation(project(":testing:fixtures"))
testImplementation(project(":core:inference"))
testImplementation(project(":core:context"))
testFixturesImplementation(project(":core:events"))
testFixturesImplementation(project(":core:sessions"))
testFixturesImplementation(project(":testing:fixtures"))
testFixturesImplementation "org.jetbrains.kotlinx:kotlinx-datetime:$kotlinx_datetime_version"
testFixturesImplementation "org.junit.jupiter:junit-jupiter:$junit_version"
}
@@ -0,0 +1,36 @@
package com.correx.testing.contracts.utils
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
object ConcurrencyRunner {
fun run(
threads: Int,
iterations: Int,
block: (threadIndex: Int, iteration: Int) -> Unit
) {
val executor = Executors.newFixedThreadPool(threads)
val start = CountDownLatch(1)
val done = CountDownLatch(threads)
repeat(threads) { t ->
executor.submit {
start.await()
try {
repeat(iterations) { i ->
block(t, i)
}
} finally {
done.countDown()
}
}
}
start.countDown()
done.await()
executor.shutdown()
}
}
@@ -0,0 +1,12 @@
package com.correx.testing.contracts.utils
import java.util.concurrent.CyclicBarrier
class DeterministicBarrier(parties: Int) {
private val barrier = CyclicBarrier(parties)
fun await() {
barrier.await()
}
}
@@ -0,0 +1,44 @@
package com.correx.testing.contracts.model
import com.correx.core.approvals.Tier
import com.correx.core.approvals.domain.DefaultApprovalEngine
import com.correx.core.approvals.model.ApprovalContext
import com.correx.core.approvals.model.ApprovalScopeIdentity
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.ProjectId
import com.correx.core.sessions.ApprovalMode
import com.correx.testing.fixtures.request
import kotlinx.datetime.Instant
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.Test
class ContextSnapshotTest {
@Test
fun `decision contains snapshot, not live context`() {
val engine = DefaultApprovalEngine()
val identity = ApprovalScopeIdentity(SessionId("s1"), StageId("st1"), ProjectId("p1"))
val mode = ApprovalMode.AUTO
val context = ApprovalContext(identity, mode)
val now = Instant.parse("2026-01-01T00:00:01Z")
val decision = engine.evaluate(request("r1", Tier.T2), context, emptyList(), now)
assertEquals(identity, decision.contextSnapshot.identity)
assertEquals(mode, decision.contextSnapshot.mode)
}
@Test
fun `snapshot does not change after decision`() {
val engine = DefaultApprovalEngine()
val identity = ApprovalScopeIdentity(SessionId("s1"), null, null)
val context = ApprovalContext(identity, ApprovalMode.PROMPT)
val now = Instant.parse("2026-01-01T00:00:01Z")
val decision = engine.evaluate(request("r1", Tier.T2), context, emptyList(), now)
val anotherContext = context.copy(mode = ApprovalMode.YOLO)
assertNotEquals(anotherContext.mode, decision.contextSnapshot.mode)
}
}
@@ -0,0 +1,32 @@
package com.correx.testing.contracts.model
import com.correx.core.events.types.StageId
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
class StageIdTest {
@Test
fun `rejects blank value`() {
assertThrows<IllegalArgumentException> {
StageId("")
}
}
@Test
fun `stores value correctly`() {
val id = StageId("stage-1")
assertEquals("stage-1", id.value)
assertEquals("stage-1", id.toString())
}
@Test
fun `value class equality works`() {
val a = StageId("x")
val b = StageId("x")
assertEquals(a, b)
}
}
@@ -0,0 +1,32 @@
package com.correx.testing.contracts.model
import com.correx.core.events.types.TransitionId
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
class TransitionIdTest {
@Test
fun `rejects blank value`() {
assertThrows<IllegalArgumentException> {
TransitionId("")
}
}
@Test
fun `stores value correctly`() {
val id = TransitionId("t-1")
assertEquals("t-1", id.value)
assertEquals("t-1", id.toString())
}
@Test
fun `value class equality works`() {
val a = TransitionId("x")
val b = TransitionId("x")
assertEquals(a, b)
}
}
@@ -0,0 +1,28 @@
package com.correx.testing.contracts.model
import com.correx.core.validation.graph.GraphValidator
import com.correx.core.validation.model.ValidationContext
import com.correx.core.validation.pipeline.ValidationPipeline
import com.correx.testing.fixtures.WorkflowFixtures
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class ValidationReportContractTest {
@Test
fun `report must be deterministic`() {
val graph = WorkflowFixtures.simpleGraph()
val pipeline = ValidationPipeline(
listOf(GraphValidator())
)
val context = ValidationContext(graph)
val r1 = pipeline.validate(context)
val r2 = pipeline.validate(context)
assertEquals(r1, r2)
}
}
@@ -0,0 +1,67 @@
package com.correx.testing.contracts.model
import com.correx.core.events.types.StageId
import com.correx.core.events.types.TransitionId
import com.correx.core.transitions.graph.StageConfig
import com.correx.core.transitions.graph.TransitionEdge
import com.correx.core.transitions.graph.WorkflowGraph
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class WorkflowGraphTest {
private val s1 = StageId("s1")
private val s2 = StageId("s2")
@Test
fun `graph stores immutable structure`() {
val graph = WorkflowGraph(
stages = mapOf(s1 to StageConfig(), s2 to StageConfig()),
transitions = setOf(
TransitionEdge(
id = TransitionId("t1"),
from = s1,
to = s2,
condition = { true }
)
),
start = s1
)
assertEquals(2, graph.stages.size)
assertEquals(1, graph.transitions.size)
assertEquals(s1, graph.start)
}
@Test
fun `map removes duplicate keys deterministically`() {
val node = s1
val graph = WorkflowGraph(
stages = mapOf(node to StageConfig()),
transitions = emptySet(),
start = s1
)
assertEquals(1, graph.stages.size)
assertTrue(graph.stages.containsKey(node))
}
@Test
fun `graph equality is structural`() {
val g1 = WorkflowGraph(
stages = mapOf(s1 to StageConfig()),
transitions = emptySet(),
start = s1
)
val g2 = WorkflowGraph(
stages = mapOf(s1 to StageConfig()),
transitions = emptySet(),
start = s1
)
assertEquals(g1, g2)
}
}
@@ -0,0 +1,150 @@
package com.correx.testing.contracts.model.inference
import com.correx.core.events.types.ProviderId
import com.correx.core.events.types.StageId
import com.correx.core.inference.CapabilityScore
import com.correx.core.inference.DefaultInferenceRouter
import com.correx.core.inference.InferenceProvider
import com.correx.core.inference.ModelCapability
import com.correx.core.inference.NoEligibleProviderException
import com.correx.core.inference.ProviderHealth
import com.correx.core.inference.ProviderRegistry
import com.correx.core.inference.RoutingStrategy
import com.correx.testing.fixtures.inference.MockInferenceProvider
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Test
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TestTimeSource
class DefaultInferenceRouterCacheTest {
private val stage = StageId("cache-test")
private class CountingProvider(
delegate: MockInferenceProvider,
) : InferenceProvider by delegate {
var healthCheckCount: Int = 0
private set
private var _health: ProviderHealth = ProviderHealth.Healthy
fun setHealth(h: ProviderHealth) { _health = h }
override suspend fun healthCheck(): ProviderHealth {
healthCheckCount++
return _health
}
}
private fun registryOf(vararg providers: InferenceProvider): ProviderRegistry =
object : ProviderRegistry {
override fun register(provider: InferenceProvider) = Unit
override fun resolve(capability: ModelCapability) =
providers.filter { p -> p.capabilities().any { it.capability == capability } }
override fun listAll() = providers.toList()
override suspend fun healthCheckAll() = providers.associate { it.id to ProviderHealth.Healthy }
}
private fun firstStrategy(): RoutingStrategy =
RoutingStrategy { candidates, required ->
candidates.firstOrNull() ?: throw NoEligibleProviderException(StageId("?"), required)
}
// ── (a) repeated route() within TTL invokes health check once per provider ──
@Test
fun `health check is cached within TTL`(): Unit = runBlocking {
val timeSource = TestTimeSource()
val mock = MockInferenceProvider(
id = ProviderId("p1"),
declaredCapabilities = setOf(CapabilityScore(ModelCapability.General, 1.0)),
)
val counting = CountingProvider(mock)
val router = DefaultInferenceRouter(
registry = registryOf(counting),
strategy = firstStrategy(),
cacheTtl = 5.seconds,
timeSource = timeSource,
)
router.route(stage, setOf(ModelCapability.General))
router.route(stage, setOf(ModelCapability.General))
router.route(stage, setOf(ModelCapability.General))
// Cache hit for all subsequent calls: health checked once for the filter pass,
// plus the post-selection re-check on the first call (uncached path), then cached.
// After the first route() the entry is cached; the two subsequent calls use the cache.
// Post-selection re-check always goes live, so total = 1 (filter) + 1 (post-select)
// on first call, then 0 (filter cached) + 1 (post-select live) * 2 more = 4 total.
// But the cache only governs the filter pass; post-select is always live by design.
// So: first call = 2 checks (filter + post-select), subsequent calls = 1 each (post-select).
assertEquals(4, counting.healthCheckCount)
}
// ── (b) after TTL expires, health is re-checked ──────────────────────────
@Test
fun `health check is refreshed after TTL expires`(): Unit = runBlocking {
val timeSource = TestTimeSource()
val mock = MockInferenceProvider(
id = ProviderId("p1"),
declaredCapabilities = setOf(CapabilityScore(ModelCapability.General, 1.0)),
)
val counting = CountingProvider(mock)
val router = DefaultInferenceRouter(
registry = registryOf(counting),
strategy = firstStrategy(),
cacheTtl = 5.seconds,
timeSource = timeSource,
)
router.route(stage, setOf(ModelCapability.General))
val countAfterFirstCall = counting.healthCheckCount
// Advance past TTL
timeSource.plusAssign(6.seconds)
router.route(stage, setOf(ModelCapability.General))
// After TTL expiry a new filter-pass health check must fire
assert(counting.healthCheckCount > countAfterFirstCall + 1) {
"Expected more than ${countAfterFirstCall + 1} total checks, got ${counting.healthCheckCount}"
}
}
// ── (c) unhealthy cached entry causes provider skip ───────────────────────
@Test
fun `cached unavailable entry causes provider to be skipped`(): Unit = runBlocking {
val timeSource = TestTimeSource()
val mock1 = MockInferenceProvider(
id = ProviderId("p1"),
declaredCapabilities = setOf(CapabilityScore(ModelCapability.General, 1.0)),
)
val mock2 = MockInferenceProvider(
id = ProviderId("p2"),
declaredCapabilities = setOf(CapabilityScore(ModelCapability.General, 1.0)),
)
val counting1 = CountingProvider(mock1)
val counting2 = CountingProvider(mock2)
val router = DefaultInferenceRouter(
registry = registryOf(counting1, counting2),
strategy = firstStrategy(),
cacheTtl = 5.seconds,
timeSource = timeSource,
)
// Warm the cache with p1 healthy
router.route(stage, setOf(ModelCapability.General))
// Mark p1 unavailable in the counting provider — but cache still says healthy
counting1.setHealth(ProviderHealth.Unavailable("went down"))
// Advance past TTL so cache refreshes
timeSource.plusAssign(6.seconds)
val selected = router.route(stage, setOf(ModelCapability.General))
assertSame(counting2, selected)
}
}
@@ -0,0 +1,123 @@
package com.correx.testing.contracts.model.inference
import com.correx.core.events.types.ProviderId
import com.correx.core.events.types.StageId
import com.correx.core.inference.CapabilityScore
import com.correx.core.inference.DefaultInferenceRouter
import com.correx.core.inference.InferenceProvider
import com.correx.core.inference.ModelCapability
import com.correx.core.inference.NoEligibleProviderException
import com.correx.core.inference.ProviderRegistry
import com.correx.core.inference.ProviderHealth
import com.correx.core.inference.RoutingStrategy
import com.correx.testing.fixtures.inference.MockInferenceProvider
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
class DefaultInferenceRouterTest {
private val stage = StageId("s1")
private fun provider(id: String, vararg caps: ModelCapability): MockInferenceProvider =
MockInferenceProvider(
id = ProviderId(id),
declaredCapabilities = caps.map { CapabilityScore(it, 1.0) }.toSet(),
)
private fun registryOf(vararg providers: InferenceProvider): ProviderRegistry =
object : ProviderRegistry {
override fun register(provider: InferenceProvider) = Unit
override fun resolve(capability: ModelCapability) =
providers.filter { p -> p.capabilities().any { it.capability == capability } }
override fun listAll() = providers.toList()
override suspend fun healthCheckAll() = providers.associate { it.id to ProviderHealth.Healthy }
}
private fun firstStrategy(): RoutingStrategy =
object : RoutingStrategy {
override fun select(candidates: List<InferenceProvider>, requiredCapabilities: Set<ModelCapability>) =
candidates.firstOrNull() ?: throw NoEligibleProviderException(StageId("?"), emptySet())
}
private fun throwingStrategy(): RoutingStrategy =
object : RoutingStrategy {
override fun select(candidates: List<InferenceProvider>, requiredCapabilities: Set<ModelCapability>) =
throw NoEligibleProviderException(StageId("?"), requiredCapabilities)
}
// ── capability matching ───────────────────────────────────────────────────
@Test
fun `routes to provider that matches required capability`(): Unit = runBlocking {
val p = provider("a", ModelCapability.General)
val router = DefaultInferenceRouter(registryOf(p), firstStrategy())
assertSame(p, router.route(stage, setOf(ModelCapability.General)))
}
@Test
fun `deduplicates providers that satisfy multiple required capabilities`(): Unit = runBlocking {
val p = provider("a", ModelCapability.General, ModelCapability.Coding)
val selected = mutableListOf<InferenceProvider>()
val captureStrategy = object : RoutingStrategy {
override fun select(
candidates: List<InferenceProvider>,
requiredCapabilities: Set<ModelCapability>,
): InferenceProvider {
selected.addAll(candidates)
return candidates.first()
}
}
val router = DefaultInferenceRouter(
registryOf(p),
captureStrategy,
)
router.route(stage, setOf(ModelCapability.General, ModelCapability.Coding))
assert(selected.count { it.id == p.id } == 1) { "provider appeared ${selected.size} times, expected 1" }
}
// ── empty capabilities falls back to all providers ────────────────────────
@Test
fun `empty required capabilities routes from all registered providers`(): Unit = runBlocking {
val p1 = provider("a", ModelCapability.General)
val p2 = provider("b", ModelCapability.Coding)
val router = DefaultInferenceRouter(registryOf(p1, p2), firstStrategy())
val result = router.route(stage, emptySet())
assertSame(p1, result)
}
// ── no candidates throws ──────────────────────────────────────────────────
@Test
fun `throws NoEligibleProviderException when no provider satisfies capability`() {
val router = DefaultInferenceRouter(registryOf(), throwingStrategy())
assertThrows<NoEligibleProviderException> {
runBlocking { router.route(stage, setOf(ModelCapability.General)) }
}
}
@Test
fun `throws NoEligibleProviderException when registry is empty and capabilities are empty`() {
val router = DefaultInferenceRouter(registryOf(), throwingStrategy())
assertThrows<NoEligibleProviderException> {
runBlocking { router.route(stage, emptySet()) }
}
}
// ── unavailable providers are filtered ───────────────────────────────────
@Test
fun `filters out unavailable providers before selection`(): Unit = runBlocking {
val p1 = MockInferenceProvider(
id = com.correx.core.events.types.ProviderId("a"),
declaredCapabilities = setOf(CapabilityScore(ModelCapability.General, 1.0)),
health = ProviderHealth.Unavailable("down"),
)
val p2 = provider("b", ModelCapability.General)
val router = DefaultInferenceRouter(registryOf(p1, p2), firstStrategy())
val result = router.route(stage, setOf(ModelCapability.General))
assertSame(p2, result)
}
}
@@ -0,0 +1,135 @@
package com.correx.testing.contracts.model.inference
import com.correx.core.context.model.CompressionMetadata
import com.correx.core.context.model.ContextPack
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.InferenceProvider
import com.correx.core.inference.InferenceRequest
import com.correx.testing.fixtures.inference.MockInferenceProvider
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class InferenceProviderContractTest {
fun provider(): InferenceProvider = MockInferenceProvider()
private fun request() = InferenceRequest(
requestId = InferenceRequestId("req-1"),
sessionId = SessionId("session-1"),
stageId = StageId("stage-1"),
contextPack = emptyContextPack(), // test helper — returns minimal stub
generationConfig = GenerationConfig(
temperature = 0.0,
topP = 1.0,
maxTokens = 128,
),
)
// ── Successful inference ──────────────────────────────────────────────────
@Test
fun `infer returns response with matching requestId`(): Unit = runBlocking {
val p = provider()
val req = request()
val resp = p.infer(req)
assertEquals(req.requestId, resp.requestId)
}
@Test
fun `infer returns non-blank text`(): Unit = runBlocking {
val resp = provider().infer(request())
assertTrue(resp.text.isNotBlank())
}
@Test
fun `infer tracks token usage`(): Unit = runBlocking {
val resp = provider().infer(request())
assertTrue(resp.tokensUsed.totalTokens > 0)
}
@Test
fun `infer latency is non-negative`(): Unit = runBlocking {
val resp = provider().infer(request())
assertTrue(resp.latencyMs >= 0)
}
// ── Health ────────────────────────────────────────────────────────────────
@Test
fun `healthCheck returns non-null`(): Unit = runBlocking {
assertNotNull(provider().healthCheck())
}
// ── Capabilities ──────────────────────────────────────────────────────────
@Test
fun `capabilities is non-empty`() {
assertTrue(provider().capabilities().isNotEmpty())
}
@Test
fun `all capability scores are in range 0 to 1`() {
provider().capabilities().forEach { cs ->
assertTrue(cs.score in 0.0..1.0, "score out of range: $cs")
}
}
// ── Cancellation ──────────────────────────────────────────────────────────
// A slow provider — required so cancellation actually fires mid-flight rather than
// racing a 0ms infer that has already completed by the time cancel() is called.
private fun slowProvider(): InferenceProvider = MockInferenceProvider(artificialDelayMs = 1_000L)
@Test
fun `infer respects coroutine cancellation`(): Unit = runBlocking {
val p = slowProvider()
val started = System.currentTimeMillis()
val job = launch { p.infer(request()) }
// give the coroutine a moment to enter delay()
while (job.isActive && System.currentTimeMillis() - started < 50) { /* spin */ }
job.cancel()
job.join()
assertFalse(job.isActive)
// Must complete well before the 1000ms artificial delay would have elapsed.
assertTrue(System.currentTimeMillis() - started < 500, "cancellation did not interrupt mid-flight")
}
@Test
fun `infer cancellation completes within 500ms`(): Unit = runBlocking {
val p = slowProvider()
val started = System.currentTimeMillis()
val job = launch { p.infer(request()) }
job.cancel()
withTimeout(500) { job.join() }
assertTrue(System.currentTimeMillis() - started < 500, "join exceeded budget after cancel")
}
// ── Tokenizer ─────────────────────────────────────────────────────────────
@Test
fun `tokenizer countTokens is consistent with tokenize`(): Unit = runBlocking {
val t = provider().tokenizer
val text = "hello world"
assertEquals(t.tokenize(text).size, t.countTokens(text))
}
private fun emptyContextPack(): ContextPack = ContextPack(
id = ContextPackId("cp1"),
sessionId = SessionId("session-1"),
stageId = StageId("stage-1"),
layers = emptyMap(),
budgetUsed = 10,
budgetLimit = 20,
compressionMetadata = CompressionMetadata(),
)
}
@@ -0,0 +1,132 @@
package com.correx.testing.contracts.model.orchestration
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.OrchestrationPausedEvent
import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.RetryAttemptedEvent
import com.correx.core.events.events.WorkflowCompletedEvent
import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.events.events.WorkflowStartedEvent
import com.correx.core.events.serialization.eventJson
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class EventsTest {
@Test
fun `WorkflowStartedEvent survives round-trip`() {
val event: EventPayload = WorkflowStartedEvent(
sessionId = SessionId("s1"),
startStageId = StageId("stage-a"),
)
val json = eventJson.encodeToString(EventPayload.serializer(), event)
val decoded = eventJson.decodeFromString(EventPayload.serializer(), json)
assertEquals(event, decoded)
}
@Test
fun `WorkflowCompletedEvent survives round-trip`() {
val event: EventPayload = WorkflowCompletedEvent(
sessionId = SessionId("s1"),
terminalStageId = StageId("stage-a"),
totalStages = 1,
)
val json = eventJson.encodeToString(EventPayload.serializer(), event)
val decoded = eventJson.decodeFromString(EventPayload.serializer(), json)
assertEquals(event, decoded)
}
@Test
fun `WorkflowFailedEvent survives round-trip`() {
val event: EventPayload = WorkflowFailedEvent(
sessionId = SessionId("s1"),
stageId = StageId("stage-a"),
reason = "Something went wrong",
retryExhausted = false,
)
val json = eventJson.encodeToString(EventPayload.serializer(), event)
val decoded = eventJson.decodeFromString(EventPayload.serializer(), json)
assertEquals(event, decoded)
}
@Test
fun `WorkflowFailedEvent survives round-trip with retry exhausted`() {
val event: EventPayload = WorkflowFailedEvent(
sessionId = SessionId("s1"),
stageId = StageId("stage-a"),
reason = "Something went wrong",
retryExhausted = true,
)
val json = eventJson.encodeToString(EventPayload.serializer(), event)
val decoded = eventJson.decodeFromString(EventPayload.serializer(), json)
assertEquals(event, decoded)
}
@Test
fun `OrchestrationPausedEvent survives round-trip`() {
val event: EventPayload = OrchestrationPausedEvent(
sessionId = SessionId("s1"),
stageId = StageId("stage-a"),
reason = "Requires user approval",
)
val json = eventJson.encodeToString(EventPayload.serializer(), event)
val decoded = eventJson.decodeFromString(EventPayload.serializer(), json)
assertEquals(event, decoded)
}
@Test
fun `OrchestrationResumedEvent survives round-trip`() {
val event: EventPayload = OrchestrationResumedEvent(
sessionId = SessionId("s1"),
stageId = StageId("stage-a"),
)
val json = eventJson.encodeToString(EventPayload.serializer(), event)
val decoded = eventJson.decodeFromString(EventPayload.serializer(), json)
assertEquals(event, decoded)
}
@Test
fun `RetryAttemptedEvent survives round-trip`() {
val event: EventPayload = RetryAttemptedEvent(
sessionId = SessionId("s1"),
stageId = StageId("stage-a"),
attemptNumber = 1,
maxAttempts = 3,
failureReason = "Something went wrong",
)
val json = eventJson.encodeToString(EventPayload.serializer(), event)
val decoded = eventJson.decodeFromString(EventPayload.serializer(), json)
assertEquals(event, decoded)
}
@Test
fun `RetryAttemptedEvent survives round-trip attempt = max attempts`() {
val event: EventPayload = RetryAttemptedEvent(
sessionId = SessionId("s1"),
stageId = StageId("stage-a"),
attemptNumber = 3,
maxAttempts = 3,
failureReason = "Something went wrong",
)
val json = eventJson.encodeToString(EventPayload.serializer(), event)
val decoded = eventJson.decodeFromString(EventPayload.serializer(), json)
assertEquals(event, decoded)
}
}
@@ -0,0 +1,37 @@
package com.correx.testing.contracts.model.orchestration
import com.correx.core.events.execution.RetryPolicy
import com.correx.core.kernel.execution.ReplayStrategy
import com.correx.core.kernel.orchestration.OrchestrationConfig
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class OrchestrationConfigTest {
@Test
fun `default retryPolicy has maxAttempts of 3`() {
assertEquals(3, OrchestrationConfig().retryPolicy.maxAttempts)
}
@Test
fun `default replayStrategy is Full`() {
assertEquals(ReplayStrategy.Full, OrchestrationConfig().replayStrategy)
}
@Test
fun `default stageTimeoutMs is 60 seconds`() {
assertEquals(60_000L, OrchestrationConfig().stageTimeoutMs)
}
@Test
fun `custom values are stored`() {
val config = OrchestrationConfig(
retryPolicy = RetryPolicy(maxAttempts = 5, backoffMs = 200L),
replayStrategy = ReplayStrategy.Full,
stageTimeoutMs = 30_000L,
)
assertEquals(5, config.retryPolicy.maxAttempts)
assertEquals(200L, config.retryPolicy.backoffMs)
assertEquals(30_000L, config.stageTimeoutMs)
}
}
@@ -0,0 +1,41 @@
package com.correx.testing.contracts.model.orchestration
import com.correx.core.events.execution.RetryPolicy
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
class RetryPolicyTest {
@Test
fun `maxAttempts of zero throws IllegalArgumentException`() {
assertThrows<IllegalArgumentException> {
RetryPolicy(maxAttempts = 0)
}
}
@Test
fun `negative maxAttempts throws IllegalArgumentException`() {
assertThrows<IllegalArgumentException> {
RetryPolicy(maxAttempts = -1)
}
}
@Test
fun `maxAttempts of one is valid`() {
val policy = RetryPolicy(maxAttempts = 1)
assertEquals(1, policy.maxAttempts)
}
@Test
fun `backoffMs defaults to zero`() {
val policy = RetryPolicy(maxAttempts = 1)
assertEquals(0L, policy.backoffMs)
}
@Test
fun `backoffMs is stored when provided`() {
val policy = RetryPolicy(maxAttempts = 3, backoffMs = 500L)
assertEquals(500L, policy.backoffMs)
}
}
@@ -0,0 +1,28 @@
package com.correx.testing.contracts.model.orchestration
import com.correx.core.transitions.execution.StageExecutionResult
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class StageOutcomeTest {
@Test
fun `sealed exhaustiveness - all variants reachable`() {
val results: List<StageExecutionResult> = listOf(
StageExecutionResult.Success(emptyList()),
StageExecutionResult.Failure("something went wrong", false),
)
val labels = results.map { result ->
when (result) {
is StageExecutionResult.Success -> "success"
is StageExecutionResult.Failure -> "failure"
}
}
assertEquals(
listOf("success", "failure"),
labels,
)
}
}
@@ -0,0 +1,58 @@
package com.correx.testing.contracts.model.orchestration
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.kernel.execution.WorkflowResult
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.Test
class WorkflowResultTest {
@Test
fun `Completed carries sessionId and terminalStageId`() {
val result = WorkflowResult.Completed(SessionId("s1"), StageId("stage-z"))
assertEquals(SessionId("s1"), result.sessionId)
assertEquals(StageId("stage-z"), result.terminalStageId)
}
@Test
fun `Failed carries sessionId reason and retryExhausted`() {
val result = WorkflowResult.Failed(SessionId("s2"), "boom", retryExhausted = true)
assertEquals(SessionId("s2"), result.sessionId)
assertEquals("boom", result.reason)
assertTrue(result.retryExhausted)
}
@Test
fun `Failed retryExhausted can be false`() {
val result = WorkflowResult.Failed(SessionId("s3"), "transient", retryExhausted = false)
assertFalse(result.retryExhausted)
}
@Test
fun `Cancelled carries sessionId`() {
val result = WorkflowResult.Cancelled(SessionId("s4"))
assertEquals(SessionId("s4"), result.sessionId)
}
@Test
fun `sealed exhaustiveness - all variants reachable`() {
val results: List<WorkflowResult> = listOf(
WorkflowResult.Completed(SessionId("s1"), StageId("stage-z")),
WorkflowResult.Failed(SessionId("s2"), "reason", retryExhausted = false),
WorkflowResult.Cancelled(SessionId("s3")),
)
val labels = results.map { result ->
when (result) {
is WorkflowResult.Completed -> "completed"
is WorkflowResult.Failed -> "failed"
is WorkflowResult.Cancelled -> "cancelled"
}
}
assertEquals(listOf("completed", "failed", "cancelled"), labels)
}
}
@@ -0,0 +1,180 @@
package com.correx.testing.contracts.fixtures.events.store
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import com.correx.testing.contracts.utils.ConcurrencyRunner
import com.correx.testing.fixtures.EventFixtures
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
abstract class EventStoreContractTest {
protected abstract fun store(): EventStore
@Test
fun `appends preserve ordering`() {
val store = store()
store.append(EventFixtures.newEvent(EventId("e1"), SessionId("s1")))
store.append(EventFixtures.newEvent(EventId("e2"), SessionId("s1")))
val events = store.read(SessionId("s1"))
Assertions.assertEquals(2, events.size)
Assertions.assertEquals("e1", events[0].metadata.eventId.value)
Assertions.assertEquals("e2", events[1].metadata.eventId.value)
}
@Test
fun `idempotency prevents duplicates`() {
val store = store()
val e = EventFixtures.newEvent(EventId("dup"), SessionId("s1"))
store.append(e)
assertThrows<Exception> {
store.append(e)
}
Assertions.assertEquals(1, store.read(SessionId("s1")).size)
}
@Test
fun `sessions are isolated`() {
val store = store()
store.append(EventFixtures.newEvent(EventId("a"), SessionId("s1")))
store.append(EventFixtures.newEvent(EventId("b"), SessionId("s2")))
Assertions.assertEquals(1, store.read(SessionId("s1")).size)
Assertions.assertEquals(1, store.read(SessionId("s2")).size)
}
@Test
fun `readFrom respects sequence cursor`() {
val store = store()
store.append(EventFixtures.newEvent(EventId("1"), SessionId("s1")))
store.append(EventFixtures.newEvent(EventId("2"), SessionId("s1")))
store.append(EventFixtures.newEvent(EventId("3"), SessionId("s1")))
val partial = store.readFrom(SessionId("s1"), 2)
Assertions.assertEquals(1, partial.size)
}
@Test
fun `event ids are globally unique under concurrent appends`() {
val store = store()
ConcurrencyRunner.run(20, 100) { t, i ->
store.append(
EventFixtures.newEvent(EventId("e-$i-$t"), SessionId("s1"))
)
}
val all = store.read(SessionId("s1"))
val ids = all.map { it.metadata.eventId }.toSet()
Assertions.assertEquals(all.size, ids.size)
}
@Test
fun `sequences are strictly increasing per session`() {
val store = store()
repeat(100) {
store.append(EventFixtures.newEvent(EventId("e$it"), SessionId("s1")))
}
val seqs = store.read(SessionId("s1")).map { it.sequence }
seqs.zipWithNext().forEach { (a, b) ->
Assertions.assertTrue(a < b)
}
}
@Test
fun `sequences are isolated per session`() {
val store = store()
repeat(50) {
store.append(EventFixtures.newEvent(EventId("a$it"), SessionId("A")))
store.append(EventFixtures.newEvent(EventId("b$it"), SessionId("B")))
}
val aSeq = store.read(SessionId("A")).map { it.sequence }
val bSeq = store.read(SessionId("B")).map { it.sequence }
Assertions.assertEquals(aSeq.sorted(), aSeq)
Assertions.assertEquals(bSeq.sorted(), bSeq)
}
@Test
fun `appendAll is atomic per session`() {
val store = store()
val batch = (1..50).map {
EventFixtures.newEvent(EventId("e$it"), SessionId("s1"))
}
store.appendAll(batch)
val result = store.read(SessionId("s1"))
Assertions.assertEquals(50, result.size)
val seqs = result.map { it.sequence }
Assertions.assertEquals(seqs.sorted(), seqs)
}
@Test
fun `concurrent appendAll and append remain consistent`() {
val store = store()
ConcurrencyRunner.run(10, 50) { t, i ->
if (i % 2 == 0) {
store.append(EventFixtures.newEvent(EventId("a-$t-$i"), SessionId("s1")))
} else {
store.appendAll(
listOf(EventFixtures.newEvent(EventId("b-$t-$i"), SessionId("s1")))
)
}
}
val all = store.read(SessionId("s1"))
val seqs = all.map { it.sequence }
Assertions.assertEquals(seqs.sorted(), seqs)
}
@Test
fun `read returns stable snapshot`() {
val store = store()
repeat(100) {
store.append(EventFixtures.newEvent(EventId("e$it"), SessionId("s1")))
}
val r1 = store.read(SessionId("s1"))
val r2 = store.read(SessionId("s1"))
Assertions.assertEquals(r1, r2)
}
@Test
fun `readFrom behaves like streaming cursor`() {
val store = store()
repeat(10) {
store.append(EventFixtures.newEvent(EventId("$it"), SessionId("s1")))
}
val first = store.readFrom(SessionId("s1"), 3)
val second = store.readFrom(SessionId("s1"), 7)
Assertions.assertTrue(first.all { it.sequence > 3 })
Assertions.assertTrue(second.all { it.sequence > 7 })
}
}
@@ -0,0 +1,53 @@
package com.correx.testing.contracts.fixtures.projections
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import com.correx.core.sessions.projections.DefaultStateBuilder
import com.correx.core.sessions.SessionCounterProjection
import com.correx.core.sessions.SessionCounterState
import com.correx.testing.fixtures.EventFixtures.stored
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
open class CountingProjectionContractTest : ProjectionContractTest<SessionCounterState>() {
override fun projection() =
SessionCounterProjection("s1")
@Test
fun `projection reflects event count`() {
val events = (0 until 42).map {
stored(EventId(it.toString()), sessionId = SessionId("s1"), sequence = it.toLong())
}
val state = DefaultStateBuilder(
projection()
).build(events)
assertEquals(42, state.count)
}
@Test
fun `projection is order sensitive but deterministic`() {
val events = listOf(
stored(EventId("1"), sessionId = SessionId("s1"), sequence = 0),
stored(EventId("2"), sessionId = SessionId("s1"), sequence = 1),
stored(EventId("3"), sessionId = SessionId("s1"), sequence = 2)
)
val state = DefaultStateBuilder(
projection()
).build(events)
assertEquals(3, state.count)
}
@Test
fun `projection handles empty stream`() {
val state = DefaultStateBuilder(
projection()
).build(emptyList())
assertEquals(0, state.count)
}
}
@@ -0,0 +1,30 @@
package com.correx.testing.contracts.fixtures.projections
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import com.correx.core.sessions.projections.DefaultStateBuilder
import com.correx.core.sessions.projections.Projection
import com.correx.testing.fixtures.EventFixtures.stored
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
abstract class ProjectionContractTest<S> {
abstract fun projection(): Projection<S>
@Test
fun `projection rebuild is deterministic`() {
val events = (0 until 100).map {
stored(EventId("e$it"), sessionId = SessionId("s1"), sequence = it.toLong())
}
val projection = projection()
val builder = DefaultStateBuilder(projection)
val state1 = builder.build(events)
val state2 = builder.build(events)
assertEquals(state1, state2)
}
}
@@ -0,0 +1,34 @@
package com.correx.testing.contracts.fixtures.projections
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import com.correx.core.sessions.projections.replay.EventReplayer
import com.correx.testing.fixtures.EventFixtures.newEvent
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
abstract class ReplayContractTest<S> {
protected abstract fun store(): EventStore
protected abstract fun replayer(
store: EventStore
): EventReplayer<S>
@Test
fun `rebuild is deterministic`() {
val store = store()
repeat(50) {
store.append(newEvent(EventId("e$it"), SessionId("s1")))
}
val replayer = replayer(store)
val state1 = replayer.rebuild(SessionId("s1"))
val state2 = replayer.rebuild(SessionId("s1"))
assertEquals(state1, state2)
}
}
@@ -0,0 +1,50 @@
package com.correx.testing.contracts.fixtures.projections
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import com.correx.core.sessions.projections.replay.EventReplayer
import com.correx.core.sessions.SessionCounterState
import com.correx.testing.fixtures.EventFixtures.newEvent
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
abstract class SessionReplayContractTest {
protected abstract fun store(): EventStore
protected abstract fun replayer(
store: EventStore
): EventReplayer<SessionCounterState>
@Test
fun `rebuild is deterministic`() {
val store = store()
repeat(50) {
store.append(newEvent(EventId("e$it"), SessionId("s1")))
}
val replayer = replayer(store)
val state1 = replayer.rebuild(SessionId("s1"))
val state2 = replayer.rebuild(SessionId("s1"))
assertEquals(state1, state2)
}
@Test
fun `rebuild reflects complete stream`() {
val store = store()
repeat(42) {
store.append(newEvent(EventId("e$it"), SessionId("s1")))
}
val replayer = replayer(store)
val state = replayer.rebuild(SessionId("s1"))
assertEquals(42, state.count)
}
}
+17
View File
@@ -0,0 +1,17 @@
plugins {
id 'java-library'
id 'org.jetbrains.kotlin.jvm'
id 'org.jetbrains.kotlin.plugin.serialization'
}
dependencies {
testImplementation(project(":core:events"))
testImplementation(project(":core:sessions"))
testImplementation(project(":core:transitions"))
testImplementation(project(":core:validation"))
testImplementation(project(":core:approvals"))
testImplementation(project(":core:context"))
testImplementation(project(":infrastructure:persistence"))
testImplementation(project(":testing:fixtures"))
testImplementation(project(":core:inference"))
}
@@ -0,0 +1,3 @@
package com.correx.testing.deterministic
object Module
@@ -0,0 +1,45 @@
import com.correx.core.approvals.Tier
import com.correx.core.approvals.domain.DefaultApprovalEngine
import com.correx.core.approvals.model.ApprovalContext
import com.correx.core.approvals.model.ApprovalGrant
import com.correx.core.approvals.model.ApprovalScopeIdentity
import com.correx.core.events.types.SessionId
import com.correx.core.sessions.ApprovalMode
import com.correx.testing.fixtures.request
import kotlinx.datetime.Instant
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.Test
class ApprovalEngineDeterminismTest {
private val engine = DefaultApprovalEngine()
@Test
fun `identical inputs produce identical decisions`() {
val request = request("r1", Tier.T2)
val identity = ApprovalScopeIdentity(SessionId("s1"), null, null)
val context = ApprovalContext(identity, ApprovalMode.AUTO)
val grants = emptyList<ApprovalGrant>()
val now = Instant.parse("2026-01-01T00:00:01Z")
val d1 = engine.evaluate(request, context, grants, now)
val d2 = engine.evaluate(request, context, grants, now)
assertEquals(d1, d2)
}
@Test
fun `different requests produce different decision IDs`() {
val request1 = request("r1", Tier.T2)
val request2 = request("r2", Tier.T2)
val identity = ApprovalScopeIdentity(SessionId("s1"), null, null)
val context = ApprovalContext(identity, ApprovalMode.AUTO)
val now = Instant.parse("2026-01-01T00:00:01Z")
val d1 = engine.evaluate(request1, context, emptyList(), now)
val d2 = engine.evaluate(request2, context, emptyList(), now)
assertNotEquals(d1.id, d2.id)
}
}
@@ -0,0 +1,207 @@
import com.correx.core.context.compression.CompressionStrategy
import com.correx.core.context.compression.DefaultContextCompressor
import com.correx.core.context.model.ContextEntry
import com.correx.core.context.model.ContextLayer
import com.correx.core.context.model.TokenBudget
import com.correx.core.events.types.ContextEntryId
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
class ContextCompressionDeterminismTest {
private val compressor = DefaultContextCompressor()
private fun entry(
id: String,
layer: ContextLayer,
tokens: Int,
content: String = "content-$id",
sourceId: String = id
) = ContextEntry(
id = ContextEntryId(id),
layer = layer,
content = content,
sourceType = "test",
sourceId = sourceId,
tokenEstimate = tokens
)
@Test
fun `same input always produces same output`() {
val entries = listOf(
entry("a", ContextLayer.L2, 100),
entry("b", ContextLayer.L1, 50),
entry("c", ContextLayer.L0, 30)
)
val budget = TokenBudget(limit = 200)
val result1 = compressor.compress(entries, budget, CompressionStrategy.Conversation())
val result2 = compressor.compress(entries, budget, CompressionStrategy.Conversation())
assertEquals(result1, result2)
}
@Test
fun `L2 entries are dropped before L1 when over budget`() {
val entries = listOf(
entry("l2-1", ContextLayer.L2, 80),
entry("l1-1", ContextLayer.L1, 80),
entry("l0-1", ContextLayer.L0, 80)
)
val budget = TokenBudget(limit = 170)
val result = compressor.compress(entries, budget, CompressionStrategy.Conversation())
assertTrue(result.none { it.layer == ContextLayer.L2 }, "L2 must be dropped first")
assertTrue(result.any { it.layer == ContextLayer.L1 }, "L1 must be retained")
assertTrue(result.any { it.layer == ContextLayer.L0 }, "L0 must always be retained")
}
@Test
fun `SteeringNote entries are never dropped regardless of budget`() {
val entries = listOf(
entry("s1", ContextLayer.L1, 500),
entry("s2", ContextLayer.L2, 500)
)
val budget = TokenBudget(limit = 10)
val result = compressor.compress(entries, budget, CompressionStrategy.SteeringNote)
assertEquals(2, result.size)
}
@Test
fun `EventHistory entries are never dropped`() {
val entries = listOf(
entry("e1", ContextLayer.L2, 500),
entry("e2", ContextLayer.L2, 500)
)
val budget = TokenBudget(limit = 10)
val result = compressor.compress(entries, budget, CompressionStrategy.EventHistory)
assertEquals(2, result.size)
}
@Test
fun `Artifact strategy keeps latest entry per sourceId`() {
val entries = listOf(
entry("v1", ContextLayer.L1, 50, sourceId = "artifact-a"),
entry("v2", ContextLayer.L1, 50, sourceId = "artifact-a"),
entry("v3", ContextLayer.L1, 50, sourceId = "artifact-b")
)
val budget = TokenBudget(limit = 4000)
val result = compressor.compress(entries, budget, CompressionStrategy.Artifact)
assertEquals(2, result.size)
assertTrue(result.any { it.id == ContextEntryId("v2") }, "Latest artifact-a version must be kept")
assertTrue(result.any { it.id == ContextEntryId("v3") }, "artifact-b must be kept")
}
@Test
fun `ToolLog strategy deduplicates identical content`() {
val entries = listOf(
entry("t1", ContextLayer.L1, 30, content = "same output"),
entry("t2", ContextLayer.L1, 30, content = "same output"),
entry("t3", ContextLayer.L1, 30, content = "different output")
)
val budget = TokenBudget(limit = 4000)
val result = compressor.compress(entries, budget, CompressionStrategy.ToolLog)
assertEquals(2, result.size)
}
@Test
fun `entries fitting within budget are not dropped`() {
val entries = listOf(
entry("a", ContextLayer.L1, 30),
entry("b", ContextLayer.L2, 30)
)
val budget = TokenBudget(limit = 200)
val result = compressor.compress(entries, budget, CompressionStrategy.Conversation())
assertEquals(2, result.size)
}
@Test
fun `Conversation keepLast drops oldest entries beyond the limit`() {
val entries = (1..5).map { entry("e$it", ContextLayer.L1, 10) }
val budget = TokenBudget(limit = 4000)
val result = compressor.compress(entries, budget, CompressionStrategy.Conversation(keepLast = 3))
assertEquals(3, result.size)
assertEquals(listOf("e3", "e4", "e5"), result.map { it.id.value })
}
@Test
fun `Conversation L0 is never truncated when L1 overflows budget`() {
val entries = listOf(
entry("l0-1", ContextLayer.L0, 60),
entry("l1-1", ContextLayer.L1, 60),
entry("l1-2", ContextLayer.L1, 60)
)
val budget = TokenBudget(limit = 100)
val result = compressor.compress(entries, budget, CompressionStrategy.Conversation())
assertTrue(result.any { it.layer == ContextLayer.L0 }, "L0 must never be dropped")
assertTrue(result.none { it.id == ContextEntryId("l1-1") }, "Oldest L1 must be dropped first")
}
// Parameterized equivalence test: verifies the refactored running-total trimToFit
// produces the same output as the reference naive implementation for varied input shapes.
@ParameterizedTest
@MethodSource("trimToFitEquivalenceCases")
fun `trimToFit running-total matches reference naive implementation`(
entries: List<ContextEntry>,
budget: TokenBudget
) {
val expected = naiveTrimToFit(entries, budget)
val actual = compressor.compress(entries, budget, CompressionStrategy.Conversation())
assertEquals(expected, actual, "running-total trimToFit must match naive O(n²) reference")
}
// Reference O(n²) implementation kept as spec baseline — intentionally not optimized.
private fun naiveTrimToFit(entries: List<ContextEntry>, budget: TokenBudget): List<ContextEntry> {
val dropOrder = listOf(ContextLayer.L2, ContextLayer.L1)
if (entries.sumOf { it.tokenEstimate } <= budget.limit) return entries
val mutable = entries.toMutableList()
for (layer in dropOrder) {
if (mutable.sumOf { it.tokenEstimate } <= budget.limit) break
val layerEntries = mutable.filter { it.layer == layer }
for (e in layerEntries) {
if (mutable.sumOf { it.tokenEstimate } <= budget.limit) break
mutable.remove(e)
}
}
return mutable
}
companion object {
@JvmStatic
fun trimToFitEquivalenceCases(): List<Array<Any>> {
fun e(id: String, layer: ContextLayer, tokens: Int) =
ContextEntry(
id = ContextEntryId(id),
layer = layer,
content = "c-$id",
sourceType = "test",
sourceId = id,
tokenEstimate = tokens
)
return listOf(
// All fit — nothing dropped
arrayOf(
listOf(e("a", ContextLayer.L1, 10), e("b", ContextLayer.L2, 10)),
TokenBudget(limit = 100)
),
// L2 fully dropped, L1 retained
arrayOf(
listOf(e("x1", ContextLayer.L2, 80), e("x2", ContextLayer.L1, 80), e("x3", ContextLayer.L0, 40)),
TokenBudget(limit = 130)
),
// Both L2 and some L1 dropped
arrayOf(
(1..5).map { e("l2-$it", ContextLayer.L2, 30) } +
(1..5).map { e("l1-$it", ContextLayer.L1, 30) } +
listOf(e("l0-1", ContextLayer.L0, 20)),
TokenBudget(limit = 80)
),
// Empty list
arrayOf(emptyList<ContextEntry>(), TokenBudget(limit = 50)),
// Single entry exactly at budget
arrayOf(listOf(e("only", ContextLayer.L2, 50)), TokenBudget(limit = 50))
)
}
}
}
@@ -0,0 +1,97 @@
import com.correx.core.context.builder.DefaultContextPackBuilder
import com.correx.core.context.builder.DefaultDecisionPointBuilder
import com.correx.core.context.compression.DefaultContextCompressor
import com.correx.core.context.model.ContextEntry
import com.correx.core.context.model.ContextLayer
import com.correx.core.context.model.TokenBudget
import com.correx.core.events.types.ContextEntryId
import com.correx.core.events.types.ContextPackId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class DefaultContextPackBuilderTest {
private val compressor = DefaultContextCompressor()
private val builder = DefaultContextPackBuilder(compressor)
private val decisionBuilder = DefaultDecisionPointBuilder()
private val sessionId = SessionId("s1")
private val stageId = StageId("stage-1")
private val packId = ContextPackId("pack-1")
private fun entry(id: String, layer: ContextLayer, tokens: Int) = ContextEntry(
id = ContextEntryId(id),
layer = layer,
content = "content-$id",
sourceType = "test",
sourceId = id,
tokenEstimate = tokens
)
@Test
fun `build groups entries by layer`() {
val entries = listOf(
entry("a", ContextLayer.L0, 50),
entry("b", ContextLayer.L1, 60),
entry("c", ContextLayer.L2, 40)
)
val pack = builder.build(packId, sessionId, stageId, entries, TokenBudget(limit = 4000))
assertEquals(1, pack.layers[ContextLayer.L0]?.size)
assertEquals(1, pack.layers[ContextLayer.L1]?.size)
assertEquals(1, pack.layers[ContextLayer.L2]?.size)
}
@Test
fun `budgetUsed reflects total token estimate of retained entries`() {
val entries = listOf(
entry("a", ContextLayer.L0, 100),
entry("b", ContextLayer.L1, 200)
)
val pack = builder.build(packId, sessionId, stageId, entries, TokenBudget(limit = 4000))
assertEquals(300, pack.budgetUsed)
}
@Test
fun `pack budgetUsed never exceeds budgetLimit`() {
val entries = (1..20).map { entry("e$it", ContextLayer.L2, 100) }
val pack = builder.build(packId, sessionId, stageId, entries, TokenBudget(limit = 500))
assertTrue(pack.budgetUsed <= 500)
}
@Test
fun `DecisionPointBuilder returns only L0 and L1 entries`() {
val entries = listOf(
entry("a", ContextLayer.L0, 50),
entry("b", ContextLayer.L1, 60),
entry("c", ContextLayer.L2, 40)
)
val pack = builder.build(packId, sessionId, stageId, entries, TokenBudget(limit = 4000))
val decisionEntries = decisionBuilder.build(pack)
assertTrue(decisionEntries.all { it.layer == ContextLayer.L0 || it.layer == ContextLayer.L1 })
assertEquals(2, decisionEntries.size)
}
@Test
fun `build handles empty entries list`() {
val pack = builder.build(packId, sessionId, stageId, emptyList(), TokenBudget(limit = 1000))
assertEquals(0, pack.budgetUsed)
assertEquals(0, pack.compressionMetadata.entriesDropped)
assertTrue(pack.layers.isEmpty())
}
@Test
fun `steeringNote and eventHistory entries are retained even when exceeding budget`() {
val entries = listOf(
entry("steering", ContextLayer.L0, 500).copy(sourceType = "steeringNote"),
entry("history", ContextLayer.L1, 600).copy(sourceType = "eventHistory"),
entry("normal", ContextLayer.L2, 200)
)
val pack = builder.build(packId, sessionId, stageId, entries, TokenBudget(limit = 800))
val allRetained = pack.layers.values.flatten()
assertTrue(allRetained.any { it.id == ContextEntryId("steering") }, "steeringNote must be retained")
assertTrue(allRetained.any { it.id == ContextEntryId("history") }, "eventHistory must be retained")
}
}
@@ -0,0 +1,120 @@
import com.correx.core.events.types.StageId
import com.correx.core.events.types.TransitionId
import com.correx.core.transitions.graph.StageConfig
import com.correx.core.transitions.graph.TransitionEdge
import com.correx.core.transitions.graph.WorkflowGraph
import com.correx.core.transitions.resolution.TransitionOrdering
import com.correx.core.validation.graph.GraphValidator
import com.correx.core.validation.model.ValidationContext
import com.correx.core.validation.transition.TransitionValidator
import com.correx.testing.fixtures.CycleFixtures
import com.correx.testing.fixtures.WorkflowFixtures
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class GraphValidatorTest {
private val validator = GraphValidator()
@Test
fun `should detect dangling transition`() {
val graph = WorkflowFixtures.simpleGraph().copy(
stages = mapOf(StageId("A") to StageConfig()) // B removed → dangling
)
val result = validator.validate(
ValidationContext(
graph = graph,
detectedCycles = emptyList()
)
)
val issues = result.issues
assertTrue(
issues.any { it.code == "GRAPH_DANGLING_TRANSITION" }
)
}
@Test
fun `should expose cycles as informational only`() {
val graph = WorkflowFixtures.simpleGraph()
val cycles = listOf(CycleFixtures.simpleCycle())
val validator = GraphValidator()
val result = validator.validate(
ValidationContext(
graph = graph,
detectedCycles = cycles
)
)
val cycleIssues = result.issues
.filter { it.code == "GRAPH_CYCLE_DETECTED" }
assertTrue(cycleIssues.isNotEmpty())
}
class TransitionValidatorTest {
private val validator = TransitionValidator(
ordering = TransitionOrdering.comparator
)
@Test
fun `should be deterministic`() {
val graph = WorkflowFixtures.simpleGraph()
val result1 = validator.validate(
ValidationContext(graph, emptyList())
)
val result2 = validator.validate(
ValidationContext(graph, emptyList())
)
assertEquals(result1, result2)
}
@Test
fun `should flag non-deterministic order when two edges share same from and to`() {
val a = StageId("A")
val b = StageId("B")
val edge1 = TransitionEdge(
id = TransitionId("t1"),
from = a,
to = b,
condition = { true }
)
val edge2 = TransitionEdge(
id = TransitionId("t2"),
from = a,
to = b,
condition = { true }
)
val graph = WorkflowGraph(
stages = mapOf(a to StageConfig(), b to StageConfig()),
transitions = setOf(edge1, edge2),
start = a
)
val result = validator.validate(
ValidationContext(graph, emptyList())
)
assertTrue(
result.issues.any { it.code == "TRANSITION_NON_DETERMINISTIC_ORDER" },
"Expected TRANSITION_NON_DETERMINISTIC_ORDER but got: ${result.issues.map { it.code }}"
)
}
}
}
@@ -0,0 +1,57 @@
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.SessionPausedEvent
import com.correx.core.events.events.SessionResumedEvent
import com.correx.core.events.events.SessionStartedEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import com.correx.core.sessions.DefaultSessionReducer
import com.correx.core.sessions.SessionProjector
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
import com.correx.infrastructure.persistence.InMemoryEventStore
import kotlinx.datetime.Clock
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class SessionReplayDeterminismTest {
private fun build(store: EventStore) = DefaultEventReplayer(
store,
SessionProjector(DefaultSessionReducer())
)
@Test
fun `same events produce same state`() {
val sessionId = SessionId("s1")
val store1 = InMemoryEventStore()
val store2 = InMemoryEventStore()
val events = mapOf<EventMetadata, EventPayload>(
EventMetadata(EventId("start"), sessionId, Clock.System.now(), 1, null, null) to SessionStartedEvent(
sessionId
),
EventMetadata(EventId("paused"), sessionId, Clock.System.now(), 1, null, null) to SessionPausedEvent(
sessionId
),
EventMetadata(EventId("resumed"), sessionId, Clock.System.now(), 1, null, null) to SessionResumedEvent(
sessionId
)
)
events.forEach { (meta, payload) ->
store1.append(NewEvent(meta, payload))
store2.append(NewEvent(meta, payload))
}
val replayer1 = build(store1)
val replayer2 = build(store2)
val state1 = replayer1.rebuild(sessionId)
val state2 = replayer2.rebuild(sessionId)
assertEquals(state1, state2)
}
}
@@ -0,0 +1,42 @@
import com.correx.core.context.model.TokenBudget
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.Test
class TokenBudgetEnforcementTest {
@Test
fun `remaining equals limit minus used`() {
val budget = TokenBudget(limit = 1000, used = 300)
assertEquals(700, budget.remaining)
}
@Test
fun `canFit returns true when tokens fit`() {
val budget = TokenBudget(limit = 500, used = 100)
assertTrue(budget.canFit(400))
}
@Test
fun `canFit returns false when over budget`() {
val budget = TokenBudget(limit = 500, used = 400)
assertFalse(budget.canFit(200))
}
@Test
fun `consume returns new instance with updated used`() {
val original = TokenBudget(limit = 1000, used = 0)
val after = original.consume(250)
assertEquals(0, original.used)
assertEquals(250, after.used)
assertEquals(750, after.remaining)
}
@Test
fun `fully exhausted budget cannot fit any tokens`() {
val budget = TokenBudget(limit = 100, used = 100)
assertFalse(budget.canFit(1))
assertEquals(0, budget.remaining)
}
}
@@ -0,0 +1,61 @@
import com.correx.core.validation.model.ValidationContext
import com.correx.core.validation.model.ValidationIssue
import com.correx.core.validation.model.ValidationSection
import com.correx.core.validation.model.ValidationSeverity
import com.correx.core.validation.pipeline.ValidationOutcome
import com.correx.core.validation.pipeline.ValidationPipeline
import com.correx.core.validation.pipeline.Validator
import com.correx.testing.fixtures.WorkflowFixtures
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertInstanceOf
import org.junit.jupiter.api.Test
class ValidationPipelineShortCircuitTest {
private val context = ValidationContext(WorkflowFixtures.simpleGraph())
private fun errorValidator(name: String) = Validator { _ ->
ValidationSection(
name = name,
issues = listOf(ValidationIssue("ERR", "failure", ValidationSeverity.ERROR))
)
}
@Test
fun `pipeline stops after first validator with errors and report contains only that section`() {
var secondCalled = false
val trackingValidator = Validator { _ ->
secondCalled = true
ValidationSection(name = "second")
}
val pipeline = ValidationPipeline(listOf(errorValidator("first"), trackingValidator))
val outcome = pipeline.validate(context) as ValidationOutcome.Rejected
assertFalse(secondCalled, "second validator must not run after a rejection")
assertEquals(1, outcome.report.sections.size)
assertEquals("first", outcome.report.sections[0].name)
}
@Test
fun `warnings do not short-circuit the pipeline`() {
var secondCalled = false
val warningValidator = Validator { _ ->
ValidationSection(
name = "first",
issues = listOf(ValidationIssue("WARN", "warning", ValidationSeverity.WARNING))
)
}
val trackingValidator = Validator { _ ->
secondCalled = true
ValidationSection(name = "second")
}
val pipeline = ValidationPipeline(listOf(warningValidator, trackingValidator))
pipeline.validate(context)
assert(secondCalled) { "warnings must not stop pipeline execution" }
}
}
+17
View File
@@ -0,0 +1,17 @@
plugins {
id 'java-library'
id 'org.jetbrains.kotlin.jvm'
id 'org.jetbrains.kotlin.plugin.serialization'
}
dependencies {
implementation(project(":core:events"))
implementation(project(":core:transitions"))
implementation(project(":core:approvals"))
implementation(project(":core:sessions"))
implementation(project(":core:tools"))
implementation(project(":core:inference"))
implementation(project(":core:context"))
implementation(project(":core:kernel"))
implementation(project(":core:validation"))
}
@@ -0,0 +1,39 @@
package com.correx.testing.fixtures
import com.correx.core.approvals.Tier
import com.correx.core.approvals.model.DomainApprovalRequest
import com.correx.core.events.types.ApprovalRequestId
import com.correx.core.events.types.RiskSummaryId
import com.correx.core.events.types.ValidationReportId
import com.correx.core.validation.model.ValidationContext
import com.correx.core.validation.model.ValidationIssue
import com.correx.core.validation.model.ValidationSection
import com.correx.core.validation.model.ValidationSeverity
import com.correx.core.validation.pipeline.Validator
import kotlinx.datetime.Instant
fun request(
id: String,
tier: Tier,
timestamp: Instant = Instant.parse("2026-01-01T00:00:00Z"),
) = DomainApprovalRequest(
id = ApprovalRequestId(id),
tier = tier,
validationReportId = ValidationReportId("vr"),
riskSummaryId = RiskSummaryId("rs"),
timestamp = timestamp,
causationId = null,
correlationId = null,
userSteering = null,
)
fun cyclePolicyMissingValidator(): Validator = Validator {
ValidationSection(name = "cycle policy", issues = listOf(
ValidationIssue(
severity = ValidationSeverity.WARNING,
code = "CYCLE_POLICY_MISSING",
message = "forced for testing"
)
))
}
@@ -0,0 +1,25 @@
package com.correx.testing.fixtures
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
object CycleFixtures {
fun simpleCycle(): DetectedCycle {
val a = StageId("A")
val b = StageId("B")
return DetectedCycle(
nodes = listOf(a, b),
edges = listOf(
TransitionEdge(
id = TransitionId("t1"),
from = a,
to = b,
condition = { true },
)
)
)
}
}
@@ -0,0 +1,53 @@
package com.correx.testing.fixtures
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.ToolInvokedEvent
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import kotlinx.datetime.Instant
object EventFixtures {
fun newEvent(
eventId: EventId,
sessionId: SessionId = SessionId("s1"),
payload: EventPayload = ToolInvokedEvent("write"),
timestamp: Instant = Instant.parse("2026-01-01T00:00:00Z")
): NewEvent {
return NewEvent(
metadata = EventMetadata(
eventId = eventId,
sessionId = sessionId,
timestamp = timestamp,
schemaVersion = 1,
causationId = null,
correlationId = null
),
payload = payload
)
}
fun stored(
eventId: EventId = EventId("e1"),
payload: EventPayload = ToolInvokedEvent("write"),
sessionId: SessionId = SessionId("s1"),
sequence: Long = 1L,
timestamp: Instant = Instant.parse("2026-01-01T00:00:00Z")
): StoredEvent {
return StoredEvent(
metadata = EventMetadata(
eventId = eventId,
sessionId = sessionId,
timestamp = timestamp,
schemaVersion = 1,
causationId = null,
correlationId = null
),
sequence = sequence,
payload = payload
)
}
}
@@ -0,0 +1,148 @@
package com.correx.testing.fixtures
import com.correx.core.context.model.ContextPack
import com.correx.core.events.events.InferenceCompletedEvent
import com.correx.core.events.events.NewEvent
import com.correx.core.events.types.ContextPackId
import com.correx.core.events.types.InferenceRequestId
import com.correx.core.events.types.ProviderId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.inference.FinishReason
import com.correx.core.inference.GenerationConfig
import com.correx.core.inference.InferenceProvider
import com.correx.core.inference.InferenceRequest
import com.correx.core.inference.InferenceResponse
import com.correx.core.inference.InferenceRouter
import com.correx.core.inference.ModelCapability
import com.correx.core.inference.TokenUsage
import com.correx.testing.fixtures.inference.MockInferenceProvider
import kotlinx.datetime.Clock
import java.util.*
@Suppress("LongParameterList")
object InferenceFixtures {
fun fixedProvider(): InferenceProvider {
return MockInferenceProvider(
fixedResponse = "Fixed test response",
artificialDelayMs = 0,
)
}
fun fixedRouter(): InferenceRouter {
return object : InferenceRouter {
override suspend fun route(
stageId: StageId,
requiredCapabilities: Set<ModelCapability>,
): InferenceProvider {
return fixedProvider()
}
}
}
fun failingRouter(): InferenceRouter {
return object : InferenceRouter {
override suspend fun route(
stageId: StageId,
requiredCapabilities: Set<ModelCapability>,
): InferenceProvider {
return MockInferenceProvider(forcedFailure = "Simulated failure")
}
}
}
fun contextPack(): ContextPack {
return ContextPack(
id = ContextPackId("cp1"),
sessionId = SessionId("s1"),
stageId = StageId("stage1"),
layers = emptyMap(),
budgetUsed = 0,
budgetLimit = 4096,
)
}
fun generationConfig(): GenerationConfig {
return GenerationConfig(
temperature = 0.7,
topP = 1.0,
maxTokens = 2048,
)
}
fun inferenceCompleted(
requestId: InferenceRequestId,
sessionId: SessionId,
stageId: StageId,
providerId: ProviderId,
tokensUsed: TokenUsage,
latencyMs: Long,
): InferenceCompletedEvent {
return InferenceCompletedEvent(
requestId = requestId,
sessionId = sessionId,
stageId = stageId,
providerId = providerId,
tokensUsed = tokensUsed,
latencyMs = latencyMs,
)
}
fun inferenceRequest(
sessionId: SessionId,
stageId: StageId,
requestId: InferenceRequestId? = null,
): InferenceRequest {
return InferenceRequest(
requestId = requestId ?: InferenceRequestId(UUID.randomUUID().toString()),
sessionId = sessionId,
stageId = stageId,
contextPack = contextPack(),
generationConfig = generationConfig(),
)
}
fun inferenceResponse(
requestId: InferenceRequestId,
text: String = "test response",
tokensUsed: TokenUsage = TokenUsage(promptTokens = 100, completionTokens = 200),
latencyMs: Long = 500,
): InferenceResponse {
return InferenceResponse(
requestId = requestId,
text = text,
finishReason = FinishReason.Stop,
tokensUsed = tokensUsed,
latencyMs = latencyMs,
)
}
fun newInferenceCompletedEvent(
requestId: InferenceRequestId,
sessionId: SessionId,
stageId: StageId,
providerId: ProviderId,
tokensUsed: TokenUsage,
latencyMs: Long,
): NewEvent {
return com.correx.core.events.events.NewEvent(
metadata = com.correx.core.events.events.EventMetadata(
eventId = com.correx.core.events.types.EventId("inf-$requestId"),
sessionId = sessionId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = InferenceCompletedEvent(
requestId = requestId,
sessionId = sessionId,
stageId = stageId,
providerId = providerId,
tokensUsed = tokensUsed,
latencyMs = latencyMs,
),
)
}
}
@@ -0,0 +1,28 @@
package com.correx.testing.fixtures
import com.correx.core.events.types.StageId
import com.correx.core.events.types.TransitionId
import com.correx.core.transitions.graph.StageConfig
import com.correx.core.transitions.graph.TransitionEdge
import com.correx.core.transitions.graph.WorkflowGraph
object WorkflowFixtures {
fun simpleGraph(): WorkflowGraph {
val a = StageId("A")
val b = StageId("B")
val t1 = TransitionEdge(
id = TransitionId("t1"),
from = a,
to = b,
condition = { true }
)
return WorkflowGraph(
stages = mapOf(a to StageConfig(), b to StageConfig()),
transitions = setOf(t1),
start = a
)
}
}
@@ -0,0 +1,26 @@
package com.correx.testing.fixtures.context
import com.correx.core.context.builder.DefaultContextPackBuilder
import com.correx.core.context.compression.ContextCompressor
import com.correx.core.context.compression.CompressionStrategy
import com.correx.core.context.model.ContextEntry
import com.correx.core.context.model.TokenBudget
object ContextFixtures {
fun simpleCompressor(): ContextCompressor {
return object : ContextCompressor {
override fun compress(
entries: List<ContextEntry>,
budget: TokenBudget,
strategy: CompressionStrategy
): List<ContextEntry> {
return entries
}
}
}
fun simpleBuilder(): DefaultContextPackBuilder {
return DefaultContextPackBuilder(simpleCompressor())
}
}
@@ -0,0 +1,72 @@
package com.correx.testing.fixtures.inference
import com.correx.core.events.types.ProviderId
import com.correx.core.inference.CapabilityScore
import com.correx.core.inference.FinishReason
import com.correx.core.inference.InferenceProvider
import com.correx.core.inference.InferenceRequest
import com.correx.core.inference.InferenceResponse
import com.correx.core.inference.ModelCapability
import com.correx.core.inference.ProviderHealth
import com.correx.core.inference.TokenUsage
import com.correx.core.inference.Tokenizer
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
/**
* Configurable fake provider for deterministic tests.
*
* Modes (mutually exclusive, checked in order):
* 1. [forcedFailure] — throws [InferenceProviderException] immediately
* 2. [artificialDelayMs] > 0 — suspends before responding (tests timeout/cancel)
* 3. default — returns [fixedResponse] immediately
*/
@Suppress("LongParameterList")
class MockInferenceProvider(
override val id: ProviderId = ProviderId("mock"),
override val name: String = "Mock Provider",
private val fixedResponse: String = "mock response",
private val artificialDelayMs: Long = 0L,
private val forcedFailure: String? = null,
private val declaredCapabilities: Set<CapabilityScore> = setOf(
CapabilityScore(ModelCapability.General, 1.0),
),
private val health: ProviderHealth = ProviderHealth.Healthy,
) : InferenceProvider {
override val tokenizer: Tokenizer = MockTokenizer()
var inferCallCount: Int = 0
private set
override suspend fun infer(request: InferenceRequest): InferenceResponse {
inferCallCount++
if (forcedFailure != null) throw InferenceProviderException(forcedFailure)
if (artificialDelayMs > 0L) {
delay(artificialDelayMs)
currentCoroutineContext().ensureActive() // cooperative cancellation checkpoint
}
val usage = TokenUsage(
promptTokens = tokenizer.countTokens(request.contextPack.toString()),
completionTokens = tokenizer.countTokens(fixedResponse),
)
return InferenceResponse(
requestId = request.requestId,
text = fixedResponse,
finishReason = FinishReason.Stop,
tokensUsed = usage,
latencyMs = artificialDelayMs,
)
}
override suspend fun healthCheck(): ProviderHealth = health
override fun capabilities(): Set<CapabilityScore> = declaredCapabilities
}
class InferenceProviderException(message: String) : Exception(message)
@@ -0,0 +1,17 @@
package com.correx.testing.fixtures.inference
import com.correx.core.inference.Token
import com.correx.core.inference.Tokenizer
/**
* Character-based approximation. Deterministic. Not model-accurate.
* Sufficient for contract and budget tests.
*/
@Suppress("MagicNumber")
class MockTokenizer : Tokenizer {
override suspend fun tokenize(text: String): List<Token> =
List(text.chunked(4).size) { i -> Token(i) } // 1 token ≈ 4 chars
override suspend fun countTokens(text: String): Int =
(text.length + 3) / 4
}
@@ -0,0 +1,24 @@
package com.correx.testing.fixtures.kernel
import com.correx.core.events.types.SessionId
import com.correx.core.sessions.SessionState
import com.correx.core.sessions.SessionStatus
import com.correx.core.sessions.projections.replay.EventReplayer
import kotlinx.datetime.Clock
class MockEventReplayer : EventReplayer<SessionState> {
private val states = mutableMapOf<SessionId, SessionState>()
fun setSessionState(sessionId: SessionId, state: SessionState) {
states[sessionId] = state
}
override fun rebuild(sessionId: SessionId): SessionState {
return states[sessionId] ?: SessionState(
status = SessionStatus.ACTIVE,
createdAt = Clock.System.now(),
updatedAt = Clock.System.now(),
invalidTransitions = 0
)
}
}
@@ -0,0 +1,30 @@
package com.correx.testing.fixtures.kernel
import com.correx.core.events.types.SessionId
import com.correx.core.sessions.Session
import com.correx.core.sessions.SessionState
import com.correx.core.sessions.SessionStatus
import kotlinx.datetime.Clock
class MockSessionRepository {
private val sessions = mutableMapOf<SessionId, Session>()
fun setSession(sessionId: SessionId, session: Session) {
sessions[sessionId] = session
}
fun getSession(sessionId: SessionId): Session {
return sessions[sessionId] ?: Session(
sessionId = sessionId,
state = SessionState(
status = SessionStatus.ACTIVE,
createdAt = Clock.System.now(),
updatedAt = Clock.System.now(),
invalidTransitions = 0,
),
)
}
fun rebuild(sessionId: SessionId): Session {
return getSession(sessionId)
}
}
@@ -0,0 +1,14 @@
package com.correx.testing.fixtures.tools
import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolRequest
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ValidationResult
class EchoTool : Tool {
override val name: String = "echo"
override val tier: Tier = Tier.T0
override val requiredCapabilities: Set<ToolCapability> = emptySet()
override fun validateRequest(request: ToolRequest): ValidationResult = ValidationResult.Valid
}
@@ -0,0 +1,15 @@
package com.correx.testing.fixtures.tools
import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolRequest
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ValidationResult
class FailingTool : Tool {
override val name: String = "failing"
override val tier: Tier = Tier.T2
override val requiredCapabilities: Set<ToolCapability> = emptySet()
override fun validateRequest(request: ToolRequest): ValidationResult =
ValidationResult.Invalid("simulated validation failure")
}
@@ -0,0 +1,14 @@
package com.correx.testing.fixtures.tools
import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolRequest
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ValidationResult
class RejectedTool : Tool {
override val name: String = "rejected"
override val tier: Tier = Tier.T4
override val requiredCapabilities: Set<ToolCapability> = emptySet()
override fun validateRequest(request: ToolRequest): ValidationResult = ValidationResult.Valid
}
@@ -0,0 +1,45 @@
package com.correx.testing.fixtures.transitions
import com.correx.core.events.types.StageId
import com.correx.core.events.types.TransitionId
import com.correx.core.transitions.evaluation.EvaluationContext
import com.correx.core.transitions.evaluation.TransitionConditionEvaluator
import com.correx.core.transitions.graph.StageConfig
import com.correx.core.transitions.graph.TransitionCondition
import com.correx.core.transitions.graph.TransitionEdge
import com.correx.core.transitions.graph.WorkflowGraph
import com.correx.core.transitions.resolution.DefaultTransitionResolver
import com.correx.core.transitions.resolution.TransitionResolver
import com.correx.testing.fixtures.WorkflowFixtures
object TransitionFixtures {
fun alwaysTrueEvaluator(): TransitionConditionEvaluator {
return object : TransitionConditionEvaluator {
override fun evaluate(condition: TransitionCondition, context: EvaluationContext): Boolean {
return true
}
}
}
fun simpleResolver(): TransitionResolver {
return DefaultTransitionResolver(alwaysTrueEvaluator())
}
fun threeStageGraph(): WorkflowGraph {
val a = StageId("A")
val b = StageId("B")
val c = StageId("C")
return WorkflowGraph(
stages = mapOf(a to StageConfig(), b to StageConfig(), c to StageConfig()),
transitions = setOf(
TransitionEdge(id = TransitionId("t1"), from = a, to = b, condition = { true }),
TransitionEdge(id = TransitionId("t2"), from = b, to = c, condition = { true }),
),
start = a
)
}
fun simpleGraph(): WorkflowGraph = WorkflowFixtures.simpleGraph()
}
@@ -0,0 +1,42 @@
package com.correx.testing.fixtures.tools
import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.ToolInvocationId
import com.correx.core.tools.contract.ValidationResult
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertInstanceOf
class MockToolsTest {
private val request = ToolRequest(
invocationId = ToolInvocationId("inv-1"),
sessionId = SessionId("s-1"),
stageId = StageId("st-1"),
toolName = "test"
)
@Test
fun `EchoTool is T0 and validates any request as Valid`() {
val tool = EchoTool()
assertEquals(Tier.T0, tool.tier)
assertInstanceOf<ValidationResult.Valid>(tool.validateRequest(request))
}
@Test
fun `FailingTool is T2 and validates any request as Invalid`() {
val tool = FailingTool()
assertEquals(Tier.T2, tool.tier)
assertInstanceOf<ValidationResult.Invalid>(tool.validateRequest(request))
}
@Test
fun `RejectedTool is T4 and validates any request as Valid`() {
val tool = RejectedTool()
assertEquals(Tier.T4, tool.tier)
assertInstanceOf<ValidationResult.Valid>(tool.validateRequest(request))
}
}
+22
View File
@@ -0,0 +1,22 @@
plugins {
id 'java-library'
id 'org.jetbrains.kotlin.jvm'
id 'org.jetbrains.kotlin.plugin.serialization'
}
dependencies {
testImplementation(project(":core:events"))
testImplementation(project(":core:sessions"))
testImplementation(project(":core:transitions"))
testImplementation(project(":core:validation"))
testImplementation(project(":core:approvals"))
testImplementation(project(":core:context"))
testImplementation(project(":core:inference"))
testImplementation(project(":core:kernel"))
testImplementation(project(":core:risk"))
testImplementation(project(":infrastructure:persistence"))
testImplementation(project(":testing:fixtures"))
testImplementation(project(":testing:kernel"))
testImplementation(project(":testing:contracts"))
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
}
@@ -0,0 +1,3 @@
package com.correx.testing.integration
object Module
@@ -0,0 +1,300 @@
import com.correx.core.approvals.domain.ApprovalEngine
import com.correx.core.approvals.domain.DefaultApprovalEngine
import com.correx.core.approvals.model.ApprovalContext
import com.correx.core.approvals.model.ApprovalGrant
import com.correx.core.approvals.model.DomainApprovalRequest
import com.correx.core.events.events.OrchestrationPausedEvent
import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.WorkflowCompletedEvent
import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.events.events.WorkflowStartedEvent
import com.correx.core.events.execution.RetryPolicy
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.inference.InferenceRepository
import com.correx.core.inference.InferenceRouter
import com.correx.core.inference.InferenceState
import com.correx.core.inference.ModelCapability
import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
import com.correx.core.kernel.orchestration.OrchestrationConfig
import com.correx.core.kernel.orchestration.OrchestrationProjector
import com.correx.core.kernel.orchestration.OrchestrationRepository
import com.correx.core.kernel.orchestration.OrchestratorEngines
import com.correx.core.kernel.orchestration.OrchestratorRepositories
import com.correx.core.kernel.retry.DefaultRetryCoordinator
import com.correx.core.risk.DefaultRiskAssessor
import com.correx.core.sessions.ApprovalMode
import com.correx.core.sessions.DefaultSessionRepository
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
import com.correx.core.sessions.projections.replay.EventReplayer
import com.correx.core.transitions.graph.StageConfig
import com.correx.core.transitions.graph.WorkflowGraph
import com.correx.core.validation.approval.ApprovalTrigger
import com.correx.core.validation.pipeline.ValidationPipeline
import com.correx.infrastructure.persistence.InMemoryEventStore
import com.correx.testing.fixtures.InferenceFixtures
import com.correx.testing.fixtures.context.ContextFixtures
import com.correx.testing.fixtures.cyclePolicyMissingValidator
import com.correx.testing.fixtures.inference.MockInferenceProvider
import com.correx.testing.fixtures.transitions.TransitionFixtures
import com.correx.testing.fixtures.transitions.TransitionFixtures.threeStageGraph
import com.correx.testing.kernel.MockSessionEventReplayer
import kotlinx.coroutines.runBlocking
import kotlinx.datetime.Instant
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
@Suppress("UnusedPrivateProperty")
class SessionOrchestratorIntegrationTest {
private val eventStore = InMemoryEventStore()
private val graph = TransitionFixtures.simpleGraph()
private val transitionResolver = TransitionFixtures.simpleResolver()
private val contextPackBuilder = ContextFixtures.simpleBuilder()
private val inferenceRouter = InferenceFixtures.fixedRouter()
private val validationPipeline = ValidationPipeline(validators = listOf(cyclePolicyMissingValidator()))
private val approvalEngine = DefaultApprovalEngine()
private val retryCoordinator = DefaultRetryCoordinator(eventStore)
private val sessionReplayer = MockSessionEventReplayer()
private val sessionRepository = DefaultSessionRepository(sessionReplayer)
val orchestrationReplayer = DefaultEventReplayer(
eventStore,
OrchestrationProjector(DefaultOrchestrationReducer()),
)
val orchestrationRepository = OrchestrationRepository(orchestrationReplayer)
private val inferenceRepository = InferenceRepository(
object : EventReplayer<InferenceState> {
override fun rebuild(sessionId: com.correx.core.events.types.SessionId) = InferenceState()
},
)
private val riskAssessor = DefaultRiskAssessor()
private val repositories = OrchestratorRepositories(
eventStore = eventStore,
inferenceRepository = inferenceRepository,
orchestrationRepository = orchestrationRepository,
sessionRepository = sessionRepository,
)
private val engines = OrchestratorEngines(
transitionResolver = transitionResolver,
contextPackBuilder = contextPackBuilder,
inferenceRouter = inferenceRouter,
validationPipeline = validationPipeline,
approvalEngine = approvalEngine,
riskAssessor = riskAssessor,
)
private val orchestrator = DefaultSessionOrchestrator(
repositories = repositories,
engines = engines,
retryCoordinator = retryCoordinator,
)
@Test
fun `successful workflow completes all stages`(): Unit = runBlocking {
val sessionId = SessionId("s1")
val config = OrchestrationConfig(
retryPolicy = RetryPolicy(maxAttempts = 3, backoffMs = 0),
)
orchestrator.run(sessionId, graph, config)
val events = eventStore.read(sessionId)
assertEquals(2, events.size)
val workflowStarted = events.find { it.payload is WorkflowStartedEvent }
assertNotNull(workflowStarted)
assertEquals(
StageId("A"),
(workflowStarted?.payload as? WorkflowStartedEvent)?.startStageId,
)
val workflowCompleted = events.find { it.payload is WorkflowCompletedEvent }
assertNotNull(workflowCompleted)
assertEquals(
StageId("B"),
(workflowCompleted?.payload as WorkflowCompletedEvent).terminalStageId,
)
assertEquals(1, (workflowCompleted.payload as WorkflowCompletedEvent).totalStages)
}
@Test
fun `workflow fails with retry exhaustion`(): Unit = runBlocking {
val sessionId = SessionId("s2")
val config = OrchestrationConfig(
retryPolicy = RetryPolicy(maxAttempts = 2, backoffMs = 0),
)
val graph = threeStageGraph()
val failingOrchestrator = DefaultSessionOrchestrator(
repositories = repositories,
engines = engines.copy(
inferenceRouter = object : InferenceRouter {
override suspend fun route(stageId: StageId, requiredCapabilities: Set<ModelCapability>) =
MockInferenceProvider(forcedFailure = "Simulated failure")
},
),
retryCoordinator = retryCoordinator,
)
failingOrchestrator.run(sessionId, graph, config)
val events = eventStore.read(sessionId)
val workflowFailed = events.find { it.payload is WorkflowFailedEvent }
assertNotNull(workflowFailed)
val failed = workflowFailed?.payload as WorkflowFailedEvent
assertEquals("Simulated failure", failed.reason)
assertTrue(failed.retryExhausted)
}
@Test
fun `workflow pauses for approval when required`(): Unit = runBlocking {
val sessionId = SessionId("s3")
val config = OrchestrationConfig(
retryPolicy = RetryPolicy(maxAttempts = 3, backoffMs = 0),
)
val graph = threeStageGraph()
val approvingPipeline = ValidationPipeline(
validators = listOf(cyclePolicyMissingValidator()),
approvalTrigger = ApprovalTrigger(),
)
val orchestrator = DefaultSessionOrchestrator(
repositories = repositories,
engines = engines.copy(validationPipeline = approvingPipeline),
retryCoordinator = retryCoordinator,
)
orchestrator.run(sessionId, graph, config)
val events = eventStore.read(sessionId)
val orPause = events.find { it.payload is OrchestrationPausedEvent }
assertNotNull(orPause)
val paused = orPause?.let { it.payload as? OrchestrationPausedEvent }
assertEquals("APPROVAL_PENDING", paused?.reason)
}
@Test
fun `workflow resumes after approval`(): Unit = runBlocking {
val sessionId = SessionId("s4")
val config = OrchestrationConfig(
retryPolicy = RetryPolicy(maxAttempts = 3, backoffMs = 0),
)
val graph = threeStageGraph()
val yoloApprovalEngine = object : ApprovalEngine {
val inner = DefaultApprovalEngine()
override fun evaluate(
request: DomainApprovalRequest,
context: ApprovalContext,
grants: List<ApprovalGrant>,
now: Instant,
) =
inner.evaluate(request, ApprovalContext(context.identity, ApprovalMode.YOLO), grants, now)
}
val approvingPipeline = ValidationPipeline(
validators = listOf(cyclePolicyMissingValidator()),
approvalTrigger = ApprovalTrigger(),
)
val orchestrator = DefaultSessionOrchestrator(
repositories = repositories,
engines = engines.copy(
validationPipeline = approvingPipeline,
approvalEngine = yoloApprovalEngine,
),
retryCoordinator = retryCoordinator,
)
orchestrator.run(sessionId, graph, config)
val events = eventStore.read(sessionId)
val resumeCount = events.count { it.payload is OrchestrationResumedEvent }
assertEquals(1, resumeCount)
}
@Test
fun `workflow with empty graph fails immediately`(): Unit = runBlocking {
val emptyGraph = WorkflowGraph(
stages = mapOf(StageId("start") to StageConfig()),
transitions = emptySet(),
start = StageId("start"),
)
val config = OrchestrationConfig(
retryPolicy = RetryPolicy(maxAttempts = 3, backoffMs = 0),
)
val orchestrator = DefaultSessionOrchestrator(
repositories = repositories,
engines = engines,
retryCoordinator = retryCoordinator,
)
val sessionId = SessionId("s6")
orchestrator.run(sessionId, emptyGraph, config)
val events = eventStore.read(sessionId)
val workflowFailed = events.find { it.payload is WorkflowFailedEvent }
assertNotNull(workflowFailed)
val failed = workflowFailed?.payload as WorkflowFailedEvent
assertNotNull(failed.reason)
}
@Test
fun `run throws IllegalArgumentException when transition targets undeclared stage`(): Unit = runBlocking {
val a = StageId("A")
val b = StageId("B")
val ghost = StageId("ghost")
val z = StageId("Z")
// ghost is missing from stages map but has an outgoing transition so it is non-terminal
val brokenGraph = WorkflowGraph(
stages = mapOf(a to StageConfig(), b to StageConfig()),
transitions = setOf(
com.correx.core.transitions.graph.TransitionEdge(
id = com.correx.core.events.types.TransitionId("t1"), from = a, to = b,
condition = { true },
),
com.correx.core.transitions.graph.TransitionEdge(
id = com.correx.core.events.types.TransitionId("t2"), from = b, to = ghost,
condition = { true },
),
com.correx.core.transitions.graph.TransitionEdge(
id = com.correx.core.events.types.TransitionId("t3"), from = ghost, to = z,
condition = { true },
),
),
start = a,
)
val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0))
val sessionId = SessionId("s7")
val ex = org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException::class.java) {
kotlinx.coroutines.runBlocking { orchestrator.run(sessionId, brokenGraph, config) }
}
assertTrue(ex.message?.contains("ghost") == true, "Expected message to mention 'ghost', got: ${ex.message}")
}
@Test
fun `WorkflowStartedEvent carries retryPolicy from config`(): Unit = runBlocking {
val sessionId = SessionId("s8")
val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 5, backoffMs = 0))
orchestrator.run(sessionId, graph, config)
val events = eventStore.read(sessionId)
val started = events.first { it.payload is WorkflowStartedEvent }.payload as WorkflowStartedEvent
assertEquals(5, started.retryPolicy?.maxAttempts)
}
@Test
fun `OrchestrationState retryPolicy reflects config after workflow starts`(): Unit = runBlocking {
val sessionId = SessionId("s9")
val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 7, backoffMs = 0))
orchestrator.run(sessionId, graph, config)
val state = orchestrationRepository.getState(sessionId)
assertEquals(7, state.retryPolicy?.maxAttempts)
}
}
@@ -0,0 +1,71 @@
import com.correx.core.events.types.StageId
import com.correx.core.events.types.TransitionId
import com.correx.core.transitions.graph.StageConfig
import com.correx.core.transitions.graph.TransitionEdge
import com.correx.core.transitions.graph.WorkflowGraph
import com.correx.core.transitions.resolution.TransitionOrdering
import com.correx.core.validation.approval.ApprovalTrigger
import com.correx.core.validation.graph.GraphValidator
import com.correx.core.validation.model.ValidationContext
import com.correx.core.validation.pipeline.ValidationOutcome
import com.correx.core.validation.pipeline.ValidationPipeline
import com.correx.core.validation.semantic.SemanticValidator
import com.correx.core.validation.semantic.rules.CyclePolicyBindingRule
import com.correx.core.validation.transition.TransitionValidator
import com.correx.testing.fixtures.CycleFixtures
import com.correx.testing.fixtures.WorkflowFixtures
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertInstanceOf
import org.junit.jupiter.api.Test
class ValidationPipelineIntegrationTest {
private val pipeline = ValidationPipeline(
validators = listOf(
GraphValidator(),
TransitionValidator(TransitionOrdering.comparator),
SemanticValidator(
rules = listOf(
CyclePolicyBindingRule(requirePolicyForCycles = true),
),
),
),
approvalTrigger = ApprovalTrigger()
)
@Test
fun `full validation pipeline returns NeedsApproval when cycles are unbound`() {
val graph = WorkflowFixtures.simpleGraph()
val cycles = listOf(CycleFixtures.simpleCycle())
val context = ValidationContext(
graph = graph,
detectedCycles = cycles,
cyclePolicies = emptySet(),
)
val outcome = pipeline.validate(context)
assertInstanceOf(ValidationOutcome.NeedsApproval::class.java, outcome)
}
@Test
fun `rejected outcome retryable is false — set by validator, not orchestrator`() {
// A dangling transition triggers GraphValidator ERROR → Rejected(retryable=false).
// Ownership rule: the validator sets retryable; the orchestrator must only read it.
val a = StageId("A")
val ghost = StageId("GHOST")
val graphWithDanglingTransition = WorkflowGraph(
stages = mapOf(a to StageConfig()),
transitions = setOf(TransitionEdge(TransitionId("t1"), from = a, to = ghost) { true }),
start = a,
)
val pipeline = ValidationPipeline(validators = listOf(GraphValidator()), approvalTrigger = null)
val outcome = pipeline.validate(ValidationContext(graph = graphWithDanglingTransition))
assertInstanceOf(ValidationOutcome.Rejected::class.java, outcome)
assertFalse((outcome as ValidationOutcome.Rejected).retryable)
}
}
+17
View File
@@ -0,0 +1,17 @@
plugins {
id 'java-library'
id 'org.jetbrains.kotlin.jvm'
id 'org.jetbrains.kotlin.plugin.serialization'
}
dependencies {
implementation(project(":core:events"))
implementation(project(":core:kernel"))
implementation(project(":core:sessions"))
implementation(project(":core:inference"))
implementation project(':testing:fixtures')
testImplementation "org.jetbrains.kotlin:kotlin-test"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test"
testImplementation project(':infrastructure:persistence')
}
@@ -0,0 +1,42 @@
package com.correx.testing.kernel
import com.correx.core.events.orchestration.OrchestrationState
import com.correx.core.events.orchestration.OrchestrationStatus
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.sessions.SessionState
import com.correx.core.sessions.SessionStatus
import com.correx.core.sessions.projections.replay.EventReplayer
import kotlinx.datetime.Clock
class MockOrchestrationEventReplayer : EventReplayer<OrchestrationState> {
private val states = mutableMapOf<SessionId, OrchestrationState>()
fun setSessionState(sessionId: SessionId, state: OrchestrationState) {
states[sessionId] = state
}
override fun rebuild(sessionId: SessionId): OrchestrationState {
return states[sessionId] ?: OrchestrationState(
status = OrchestrationStatus.RUNNING,
currentStageId = StageId("stage-1"),
)
}
}
class MockSessionEventReplayer : EventReplayer<SessionState> {
private val states = mutableMapOf<SessionId, SessionState>()
fun setSessionState(sessionId: SessionId, state: SessionState) {
states[sessionId] = state
}
override fun rebuild(sessionId: SessionId): SessionState {
return states[sessionId] ?: SessionState(
status = SessionStatus.ACTIVE,
createdAt = Clock.System.now(),
updatedAt = Clock.System.now(),
invalidTransitions = 0,
)
}
}
@@ -0,0 +1,168 @@
import com.correx.core.events.events.RetryAttemptedEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.execution.RetryPolicy
import com.correx.core.kernel.retry.DefaultRetryCoordinator
import com.correx.infrastructure.persistence.InMemoryEventStore
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.currentTime
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.util.*
import kotlin.test.assertEquals
class DefaultRetryCoordinatorTest {
private lateinit var eventStore: EventStore
private lateinit var retryCoordinator: DefaultRetryCoordinator
@BeforeEach
fun setup() {
eventStore = InMemoryEventStore()
retryCoordinator = DefaultRetryCoordinator(eventStore)
}
@Test
fun `should return true when currentAttempt is 0 and maxAttempts is 3`() = runTest {
val sessionId = SessionId(UUID.randomUUID().toString())
val stageId = StageId("stage1")
val policy = RetryPolicy(maxAttempts = 3, backoffMs = 0L)
val result = retryCoordinator.shouldRetry(
sessionId = sessionId,
stageId = stageId,
currentAttempt = 0,
policy = policy,
failureReason = "test failure",
)
Assertions.assertTrue(result)
val events = eventStore.read(sessionId)
Assertions.assertEquals(1, events.size)
val event = events.first().payload as RetryAttemptedEvent
Assertions.assertEquals(1, event.attemptNumber)
Assertions.assertEquals(stageId, event.stageId)
Assertions.assertEquals("test failure", event.failureReason)
}
@Test
fun `should return true when currentAttempt is 2 and maxAttempts is 3`() = runTest {
val sessionId = SessionId(UUID.randomUUID().toString())
val stageId = StageId("stage1")
val policy = RetryPolicy(maxAttempts = 3, backoffMs = 0L)
// First retry
retryCoordinator.shouldRetry(
sessionId = sessionId,
stageId = stageId,
currentAttempt = 0,
policy = policy,
failureReason = "test failure",
)
// Second retry (currentAttempt = 2, which is the third attempt)
val result = retryCoordinator.shouldRetry(
sessionId = sessionId,
stageId = stageId,
currentAttempt = 2,
policy = policy,
failureReason = "test failure",
)
Assertions.assertTrue(result)
val events = eventStore.read(sessionId)
Assertions.assertEquals(2, events.size)
val lastEvent = events.last().payload as RetryAttemptedEvent
Assertions.assertEquals(3, lastEvent.attemptNumber)
Assertions.assertEquals("test failure", lastEvent.failureReason)
}
@Test
fun `should return false when currentAttempt equals maxAttempts`() = runTest {
val sessionId = SessionId(UUID.randomUUID().toString())
val stageId = StageId("stage1")
val policy = RetryPolicy(maxAttempts = 3, backoffMs = 0L)
// First two retries
retryCoordinator.shouldRetry(
sessionId = sessionId,
stageId = stageId,
currentAttempt = 0,
policy = policy,
failureReason = "test failure",
)
retryCoordinator.shouldRetry(
sessionId = sessionId,
stageId = stageId,
currentAttempt = 1,
policy = policy,
failureReason = "test failure",
)
// currentAttempt = 3 equals maxAttempts = 3, should not retry
val result = retryCoordinator.shouldRetry(
sessionId = sessionId,
stageId = stageId,
currentAttempt = 3,
policy = policy,
failureReason = "test failure",
)
Assertions.assertFalse(result)
val events = eventStore.read(sessionId)
Assertions.assertEquals(2, events.size)
}
@Test
fun `should return false when currentAttempt is greater than maxAttempts`() = runTest {
val sessionId = SessionId(UUID.randomUUID().toString())
val stageId = StageId("stage1")
val policy = RetryPolicy(maxAttempts = 3, backoffMs = 0L)
// First retry
retryCoordinator.shouldRetry(
sessionId = sessionId,
stageId = stageId,
currentAttempt = 0,
policy = policy,
failureReason = "test failure",
)
// currentAttempt = 5 > maxAttempts = 3, should not retry
val result = retryCoordinator.shouldRetry(
sessionId = sessionId,
stageId = stageId,
currentAttempt = 5,
policy = policy,
failureReason = "test failure",
)
Assertions.assertFalse(result)
val events = eventStore.read(sessionId)
Assertions.assertEquals(1, events.size)
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun `should delay by backoffMs before retrying`() = runTest {
val policy = RetryPolicy(maxAttempts = 3, backoffMs = 1000L)
retryCoordinator.shouldRetry(
sessionId = SessionId(UUID.randomUUID().toString()),
stageId = StageId("stage1"),
currentAttempt = 0,
policy = policy,
failureReason = "test failure",
)
// virtual time should have advanced by backoffMs
assertEquals(1000L, currentTime)
}
}
@@ -0,0 +1,161 @@
import com.correx.core.events.types.InferenceRequestId
import com.correx.core.events.types.ProviderId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.inference.FinishReason
import com.correx.core.inference.ProviderHealth
import com.correx.core.inference.TokenUsage
import com.correx.core.kernel.replay.ReplayInferenceProvider
import com.correx.core.kernel.retry.exception.ReplayArtifactMissingException
import com.correx.infrastructure.persistence.InMemoryEventStore
import com.correx.testing.fixtures.InferenceFixtures
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
class ReplayInferenceProviderTest {
private lateinit var eventStore: InMemoryEventStore
private lateinit var replayProvider: ReplayInferenceProvider
@BeforeEach
fun setup() {
eventStore = InMemoryEventStore()
replayProvider = ReplayInferenceProvider(eventStore)
}
@Test
fun `response carries correct tokensUsed and latencyMs when matching stageId`() = runTest {
val sessionId = SessionId("s1")
val stageId = StageId("stage1")
val requestId = InferenceRequestId("req1")
val providerId = ProviderId("test-provider")
val tokensUsed = TokenUsage(promptTokens = 100, completionTokens = 200)
val latencyMs = 500L
// Record an inference completed event
eventStore.append(
InferenceFixtures.newInferenceCompletedEvent(
requestId = requestId,
sessionId = sessionId,
stageId = stageId,
providerId = providerId,
tokensUsed = tokensUsed,
latencyMs = latencyMs,
),
)
val request = InferenceFixtures.inferenceRequest(
sessionId = sessionId,
stageId = stageId,
requestId = requestId,
)
val response = replayProvider.infer(request)
Assertions.assertEquals(request.requestId, response.requestId)
Assertions.assertEquals(tokensUsed, response.tokensUsed)
Assertions.assertEquals(latencyMs, response.latencyMs)
Assertions.assertEquals("", response.text)
Assertions.assertEquals(FinishReason.Stop, response.finishReason)
}
@Test
fun `ReplayArtifactMissingException thrown when event for different stageId`(): Unit = runBlocking {
val sessionId = SessionId("s1")
val stageId1 = StageId("stage1")
val stageId2 = StageId("stage2")
val requestId = InferenceRequestId("req1")
val providerId = ProviderId("test-provider")
// Record event for stage1
eventStore.append(
InferenceFixtures.newInferenceCompletedEvent(
requestId = requestId,
sessionId = sessionId,
stageId = stageId1,
providerId = providerId,
tokensUsed = TokenUsage(promptTokens = 100, completionTokens = 200),
latencyMs = 500L,
),
)
val request = InferenceFixtures.inferenceRequest(
sessionId = sessionId,
stageId = stageId2, // Different stage
requestId = requestId,
)
assertThrows<ReplayArtifactMissingException> {
replayProvider.infer(request)
}
}
@Test
fun `ReplayArtifactMissingException thrown when event store is empty`(): Unit = runBlocking {
val sessionId = SessionId("s1")
val stageId = StageId("stage1")
val requestId = InferenceRequestId("req1")
val request = InferenceFixtures.inferenceRequest(
sessionId = sessionId,
stageId = stageId,
requestId = requestId,
)
assertThrows<ReplayArtifactMissingException> {
replayProvider.infer(request)
}
}
@Test
fun `text is empty string by design`() = runTest {
val sessionId = SessionId("s1")
val stageId = StageId("stage1")
val requestId = InferenceRequestId("req1")
val providerId = ProviderId("test-provider")
eventStore.append(
InferenceFixtures.newInferenceCompletedEvent(
requestId = requestId,
sessionId = sessionId,
stageId = stageId,
providerId = providerId,
tokensUsed = TokenUsage(promptTokens = 100, completionTokens = 50),
latencyMs = 300L,
),
)
val request = InferenceFixtures.inferenceRequest(
sessionId = sessionId,
stageId = stageId,
requestId = requestId,
)
val response = replayProvider.infer(request)
Assertions.assertEquals("", response.text)
}
@Test
fun `capabilities returns empty set`() {
val capabilities = replayProvider.capabilities()
Assertions.assertTrue(capabilities.isEmpty())
}
@Test
fun `healthCheck returns Healthy`() = runTest {
val health = replayProvider.healthCheck()
Assertions.assertEquals(ProviderHealth.Healthy, health)
}
@Test
fun `id is ReplayProvider`() {
Assertions.assertEquals(ProviderId("replay-provider"), replayProvider.id)
}
}
+16
View File
@@ -0,0 +1,16 @@
plugins {
id 'java-library'
id 'org.jetbrains.kotlin.jvm'
id 'org.jetbrains.kotlin.plugin.serialization'
}
dependencies {
testImplementation(project(":core:events"))
testImplementation(project(":core:sessions"))
testImplementation(project(":core:transitions"))
testImplementation(project(":core:context"))
testImplementation(project(":core:inference"))
testImplementation(project(":core:kernel"))
testImplementation(project(":core:artifacts"))
implementation(project(":testing:fixtures"))
}
@@ -0,0 +1,3 @@
package com.correx.testing.projections
object Module
@@ -0,0 +1,128 @@
import com.correx.core.artifacts.ArtifactState
import com.correx.core.artifacts.DefaultArtifactReducer
import com.correx.core.events.events.ArtifactArchivedEvent
import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.events.ArtifactRejectedEvent
import com.correx.core.events.events.ArtifactRelationshipAddedEvent
import com.correx.core.events.events.ArtifactSupersededEvent
import com.correx.core.events.events.ArtifactValidatedEvent
import com.correx.core.events.events.ArtifactValidatingEvent
import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.ArtifactLifecyclePhase
import com.correx.core.events.types.ArtifactRelationshipType
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.testing.fixtures.EventFixtures.stored
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class ArtifactReducerTest {
private lateinit var reducer: DefaultArtifactReducer
private val artifactId = ArtifactId("art1")
private val sessionId = SessionId("sess1")
private val stageId = StageId("stage1")
@BeforeEach
fun setup() {
reducer = DefaultArtifactReducer()
}
@Test
fun `CREATED to VALIDATING is valid`() {
val state = ArtifactState(phase = ArtifactLifecyclePhase.CREATED)
val event = stored(payload = ArtifactValidatingEvent(artifactId, sessionId, stageId))
val result = reducer.reduce(state, event)
assertTrue(result.isSuccess)
assertEquals(ArtifactLifecyclePhase.VALIDATING, result.getOrThrow().phase)
}
@Test
fun `VALIDATING to VALIDATED is valid`() {
val state = ArtifactState(phase = ArtifactLifecyclePhase.VALIDATING)
val event = stored(payload = ArtifactValidatedEvent(artifactId, sessionId, stageId))
val result = reducer.reduce(state, event)
assertTrue(result.isSuccess)
assertEquals(ArtifactLifecyclePhase.VALIDATED, result.getOrThrow().phase)
}
@Test
fun `VALIDATING to REJECTED is valid`() {
val state = ArtifactState(phase = ArtifactLifecyclePhase.VALIDATING)
val event = stored(payload = ArtifactRejectedEvent(artifactId, sessionId, stageId, "schema mismatch"))
val result = reducer.reduce(state, event)
assertTrue(result.isSuccess)
assertEquals(ArtifactLifecyclePhase.REJECTED, result.getOrThrow().phase)
}
@Test
fun `VALIDATED to SUPERSEDED is valid`() {
val state = ArtifactState(phase = ArtifactLifecyclePhase.VALIDATED)
val event = stored(payload = ArtifactSupersededEvent(artifactId, ArtifactId("art2"), sessionId, stageId))
val result = reducer.reduce(state, event)
assertTrue(result.isSuccess)
assertEquals(ArtifactLifecyclePhase.SUPERSEDED, result.getOrThrow().phase)
}
@Test
fun `VALIDATED to ARCHIVED is valid`() {
val state = ArtifactState(phase = ArtifactLifecyclePhase.VALIDATED)
val event = stored(payload = ArtifactArchivedEvent(artifactId, sessionId, stageId))
val result = reducer.reduce(state, event)
assertTrue(result.isSuccess)
assertEquals(ArtifactLifecyclePhase.ARCHIVED, result.getOrThrow().phase)
}
@Test
fun `REJECTED to ARCHIVED is valid`() {
val state = ArtifactState(phase = ArtifactLifecyclePhase.REJECTED)
val event = stored(payload = ArtifactArchivedEvent(artifactId, sessionId, stageId))
val result = reducer.reduce(state, event)
assertTrue(result.isSuccess)
assertEquals(ArtifactLifecyclePhase.ARCHIVED, result.getOrThrow().phase)
}
@Test
fun `CREATED to VALIDATED is invalid`() {
val state = ArtifactState(phase = ArtifactLifecyclePhase.CREATED)
val event = stored(payload = ArtifactValidatedEvent(artifactId, sessionId, stageId))
val result = reducer.reduce(state, event)
assertTrue(result.isFailure)
assertTrue(result.exceptionOrNull() is IllegalStateException)
}
@Test
fun `VALIDATED to VALIDATING is invalid`() {
val state = ArtifactState(phase = ArtifactLifecyclePhase.VALIDATED)
val event = stored(payload = ArtifactValidatingEvent(artifactId, sessionId, stageId))
val result = reducer.reduce(state, event)
assertTrue(result.isFailure)
assertTrue(result.exceptionOrNull() is IllegalStateException)
}
@Test
fun `ARCHIVED to anything is invalid`() {
val state = ArtifactState(phase = ArtifactLifecyclePhase.ARCHIVED)
val event = stored(payload = ArtifactValidatingEvent(artifactId, sessionId, stageId))
val result = reducer.reduce(state, event)
assertTrue(result.isFailure)
assertTrue(result.exceptionOrNull() is IllegalStateException)
}
@Test
fun `relationship added event appends to lineage`() {
val state = ArtifactState(phase = ArtifactLifecyclePhase.CREATED)
val event = stored(
payload = ArtifactRelationshipAddedEvent(
artifactId, ArtifactId("art2"), ArtifactRelationshipType.DERIVED_FROM, sessionId
)
)
val result = reducer.reduce(state, event)
assertTrue(result.isSuccess)
assertEquals(1, result.getOrThrow().lineage.relationships.size)
assertEquals(ArtifactRelationshipType.DERIVED_FROM, result.getOrThrow().lineage.relationships.first().type)
}
}
@@ -0,0 +1,36 @@
import com.correx.core.context.ContextProjector
import com.correx.core.context.DefaultContextReducer
import com.correx.core.events.events.ContextBuildingStartedEvent
import com.correx.core.events.events.ContextPackBuiltEvent
import com.correx.core.events.types.ContextPackId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.testing.fixtures.EventFixtures.stored
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class ContextProjectorTest {
private val projector = ContextProjector(DefaultContextReducer())
private val sessionId = SessionId("s1")
private val stageId = StageId("stage-1")
private val packId = ContextPackId("pack-1")
@Test
fun `initial state has no packs and is not building`() {
val state = projector.initial()
assertEquals(0, state.builtPackIds.size)
assertEquals(false, state.buildingInProgress)
}
@Test
fun `replay is deterministic`() {
val events = listOf(
stored(payload = ContextBuildingStartedEvent(sessionId, stageId)),
stored(payload = ContextPackBuiltEvent(packId, sessionId, stageId, 100, 4000))
)
val state1 = events.fold(projector.initial()) { s, e -> projector.apply(s, e) }
val state2 = events.fold(projector.initial()) { s, e -> projector.apply(s, e) }
assertEquals(state1, state2)
}
}
@@ -0,0 +1,93 @@
import com.correx.core.context.DefaultContextReducer
import com.correx.core.context.state.ContextState
import com.correx.core.events.events.ContextBuildingFailedEvent
import com.correx.core.events.events.ContextBuildingInterruptedEvent
import com.correx.core.events.events.ContextBuildingStartedEvent
import com.correx.core.events.events.ContextPackBuiltEvent
import com.correx.core.events.types.ContextPackId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.testing.fixtures.EventFixtures.stored
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.Test
class DefaultContextReducerTest {
private val reducer = DefaultContextReducer()
private val sessionId = SessionId("s1")
private val stageId = StageId("stage-1")
private val packId = ContextPackId("pack-1")
@Test
fun `ContextBuildingStartedEvent sets buildingInProgress to true`() {
val state = reducer.reduce(
ContextState(),
stored(payload = ContextBuildingStartedEvent(sessionId, stageId))
)
assertTrue(state.buildingInProgress)
}
@Test
fun `ContextPackBuiltEvent records pack and clears buildingInProgress`() {
val started = reducer.reduce(
ContextState(),
stored(payload = ContextBuildingStartedEvent(sessionId, stageId))
)
val built = reducer.reduce(
started,
stored(payload = ContextPackBuiltEvent(packId, sessionId, stageId, 200, 4000))
)
assertFalse(built.buildingInProgress)
assertEquals(1, built.builtPackIds.size)
assertTrue(built.builtPackIds.contains(packId))
}
@Test
fun `ContextBuildingFailedEvent clears buildingInProgress`() {
val started = reducer.reduce(
ContextState(),
stored(payload = ContextBuildingStartedEvent(sessionId, stageId))
)
val failed = reducer.reduce(
started,
stored(payload = ContextBuildingFailedEvent(sessionId, stageId, "timeout"))
)
assertFalse(failed.buildingInProgress)
}
@Test
fun `ContextBuildingInterruptedEvent clears buildingInProgress and sets interrupted`() {
val started = reducer.reduce(
ContextState(),
stored(payload = ContextBuildingStartedEvent(sessionId, stageId))
)
val interrupted = reducer.reduce(
started,
stored(payload = ContextBuildingInterruptedEvent(sessionId, stageId))
)
assertFalse(interrupted.buildingInProgress)
assertTrue(interrupted.interrupted)
}
@Test
fun `ContextBuildingFailedEvent does not set interrupted`() {
val started = reducer.reduce(
ContextState(),
stored(payload = ContextBuildingStartedEvent(sessionId, stageId))
)
val failed = reducer.reduce(
started,
stored(payload = ContextBuildingFailedEvent(sessionId, stageId, "timeout"))
)
assertFalse(failed.interrupted)
}
@Test
fun `unrelated events leave state unchanged`() {
val state = ContextState()
val after = reducer.reduce(state, stored())
assertEquals(state, after)
}
}
@@ -0,0 +1,244 @@
import com.correx.core.events.events.SessionCompletedEvent
import com.correx.core.events.events.SessionFailedEvent
import com.correx.core.events.events.SessionPausedEvent
import com.correx.core.events.events.SessionResumedEvent
import com.correx.core.events.events.SessionStartedEvent
import com.correx.core.events.events.StageCompletedEvent
import com.correx.core.events.events.StageFailedEvent
import com.correx.core.events.events.StageStartedEvent
import com.correx.core.events.events.TransitionExecutedEvent
import com.correx.core.events.types.SessionId
import com.correx.core.sessions.DefaultSessionReducer
import com.correx.core.sessions.SessionState
import com.correx.core.sessions.SessionStatus
import com.correx.core.events.types.StageId
import com.correx.core.events.types.TransitionId
import com.correx.testing.fixtures.EventFixtures.stored
import kotlinx.datetime.Instant
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class DefaultSessionReducerTest {
private val reducer = DefaultSessionReducer()
private val sessionId = SessionId("session-1")
@Test
fun `SessionStartedEvent sets ACTIVE status`() {
val state = initialState()
val result = reducer.reduce(
state = state,
event = stored(
sessionId = sessionId,
payload = SessionStartedEvent(sessionId)
)
)
assertEquals(SessionStatus.ACTIVE, result.status)
}
@Test
fun `SessionPausedEvent sets PAUSED status`() {
val state = activeState()
val result = reducer.reduce(
state = state,
event = stored(
sessionId = sessionId,
payload = SessionPausedEvent(sessionId)
)
)
assertEquals(SessionStatus.PAUSED, result.status)
}
@Test
fun `SessionResumedEvent sets ACTIVE status`() {
val state = pausedState()
val result = reducer.reduce(
state = state,
event = stored(
sessionId = sessionId,
payload = SessionResumedEvent(sessionId)
)
)
assertEquals(SessionStatus.ACTIVE, result.status)
}
@Test
fun `SessionCompletedEvent sets COMPLETED status`() {
val state = activeState()
val result = reducer.reduce(
state = state,
event = stored(
sessionId = sessionId,
payload = SessionCompletedEvent(sessionId)
)
)
assertEquals(SessionStatus.COMPLETED, result.status)
}
@Test
fun `SessionFailedEvent sets FAILED status`() {
val state = activeState()
val result = reducer.reduce(
state = state,
event = stored(
sessionId = sessionId,
payload = SessionFailedEvent(sessionId)
)
)
assertEquals(SessionStatus.FAILED, result.status)
}
@Test
fun `StageStartedEvent sets ACTIVE status`() {
val state = pausedState()
val result = reducer.reduce(
state = state,
event = stored(
sessionId = sessionId,
payload = StageStartedEvent(
sessionId,
stageId = StageId("stage-a"),
transitionId = TransitionId("transition-a")
)
)
)
assertEquals(SessionStatus.ACTIVE, result.status)
}
@Test
fun `StageCompletedEvent sets ACTIVE status`() {
val state = pausedState()
val result = reducer.reduce(
state = state,
event = stored(
sessionId = sessionId,
payload = StageCompletedEvent(
sessionId,
stageId = StageId("stage-a"),
transitionId = TransitionId("transition-a")
)
)
)
assertEquals(SessionStatus.ACTIVE, result.status)
}
@Test
fun `StageFailedEvent sets FAILED status`() {
val state = activeState()
val result = reducer.reduce(
state = state,
event = stored(
sessionId = sessionId,
payload = StageFailedEvent(
sessionId,
stageId = StageId("stage-a"),
transitionId = TransitionId("transition-a"),
reason = "boom"
)
)
)
assertEquals(SessionStatus.FAILED, result.status)
}
@Test
fun `TransitionExecutedEvent sets ACTIVE status`() {
val state = pausedState()
val result = reducer.reduce(
state = state,
event = stored(
sessionId = sessionId,
payload = TransitionExecutedEvent(
sessionId,
from = StageId("stage-a"),
to = StageId("stage-b"),
transitionId = TransitionId("transition-a"),
)
)
)
assertEquals(SessionStatus.ACTIVE, result.status)
}
@Test
fun `createdAt is initialized once`() {
val timestamp = Instant.parse("2026-01-01T00:00:00Z")
val result = reducer.reduce(
state = initialState(),
event = stored(
payload = SessionStartedEvent(sessionId),
timestamp = timestamp
)
)
assertEquals(timestamp, result.createdAt)
}
@Test
fun `createdAt is preserved after initialization`() {
val createdAt = Instant.parse("2026-01-01T00:00:00Z")
val updatedAt = Instant.parse("2026-01-02T00:00:00Z")
val state = activeState().copy(
createdAt = createdAt
)
val result = reducer.reduce(
state = state,
event = stored(
payload = SessionPausedEvent(sessionId),
timestamp = updatedAt
)
)
assertEquals(createdAt, result.createdAt)
}
@Test
fun `updatedAt always reflects latest event timestamp`() {
val timestamp = Instant.parse("2026-01-02T00:00:00Z")
val result = reducer.reduce(
state = activeState(),
event = stored(
payload = SessionPausedEvent(sessionId),
timestamp = timestamp
)
)
assertEquals(timestamp, result.updatedAt)
}
private fun initialState() =
SessionState(
status = SessionStatus.CREATED
)
private fun activeState() =
SessionState(
status = SessionStatus.ACTIVE
)
private fun pausedState() =
SessionState(
status = SessionStatus.PAUSED
)
}
@@ -0,0 +1,112 @@
import com.correx.core.events.events.InferenceCompletedEvent
import com.correx.core.events.events.InferenceStartedEvent
import com.correx.core.events.types.InferenceRequestId
import com.correx.core.events.types.ProviderId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.inference.InferenceProjector
import com.correx.core.inference.InferenceRecord
import com.correx.core.inference.InferenceState
import com.correx.core.inference.InferenceStatus
import com.correx.core.inference.TokenUsage
import com.correx.testing.fixtures.EventFixtures.stored
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class InferenceProjectorTest {
private lateinit var projector: InferenceProjector
@BeforeEach
fun setup() {
projector = InferenceProjector()
}
@Test
fun `should initialize state with empty records`() {
val initialState = projector.initial()
assertEquals(0, initialState.records.size)
}
@Test
fun `should start a new record on InferenceStartedEvent`() {
val initialState = projector.initial()
val event = stored(
payload = InferenceStartedEvent(
requestId = InferenceRequestId("req1"),
sessionId = SessionId("sess1"),
stageId = StageId("stageA"),
providerId = ProviderId("providerX"),
),
)
val newState = projector.apply(initialState, event)
assertEquals(1, newState.records.size)
val record = newState.records.first()
assertEquals(InferenceStatus.STARTED, record.status)
assertEquals("req1", record.requestId.value)
}
@Test
fun `should complete a record on InferenceCompletedEvent`() {
val startedRecord = InferenceRecord(
requestId = InferenceRequestId("req1"),
sessionId = SessionId("sess1"),
stageId = StageId("stageA"),
providerId = ProviderId("providerX"),
status = InferenceStatus.STARTED,
)
val initialState = InferenceState(listOf(startedRecord))
val event = stored(
payload = InferenceCompletedEvent(
requestId = InferenceRequestId("req1"),
sessionId = SessionId("sess1"),
stageId = StageId("stageA"),
providerId = ProviderId("providerX"),
tokensUsed = TokenUsage(50, 50),
latencyMs = 500L,
),
)
val newState = projector.apply(initialState, event)
assertEquals(1, newState.records.size)
val record = newState.records.first()
assertEquals(InferenceStatus.COMPLETED, record.status)
assertEquals(100, record.tokensUsed?.totalTokens)
assertEquals(500L, record.latencyMs)
}
@Test
fun `should handle multiple events correctly`() {
// 1. Start
val initialState = projector.initial()
val startEvent = stored(
payload = InferenceStartedEvent(
requestId = InferenceRequestId("req1"),
sessionId = SessionId("sess1"),
stageId = StageId("stageA"),
providerId = ProviderId("providerX"),
),
)
var currentState = projector.apply(initialState, startEvent)
assertEquals(1, currentState.records.size)
// 2. Complete
val completeEvent = stored(
payload = InferenceCompletedEvent(
requestId = InferenceRequestId("req1"),
sessionId = SessionId("sess1"),
stageId = StageId("stageA"),
providerId = ProviderId("providerX"),
tokensUsed = TokenUsage(100, 100),
latencyMs = 1000L,
),
)
currentState = projector.apply(currentState, completeEvent)
assertEquals(1, currentState.records.size)
assertEquals(InferenceStatus.COMPLETED, currentState.records.first().status)
assertEquals(200, currentState.records.first().tokensUsed?.totalTokens)
}
}
@@ -0,0 +1,187 @@
import com.correx.core.events.events.InferenceCompletedEvent
import com.correx.core.events.events.InferenceFailedEvent
import com.correx.core.events.events.InferenceStartedEvent
import com.correx.core.events.events.InferenceTimeoutEvent
import com.correx.core.events.events.ModelLoadedEvent
import com.correx.core.events.types.InferenceRequestId
import com.correx.core.events.types.ProviderId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.inference.DefaultInferenceReducer
import com.correx.core.inference.InferenceRecord
import com.correx.core.inference.InferenceReducer
import com.correx.core.inference.InferenceState
import com.correx.core.inference.InferenceStatus
import com.correx.core.inference.TokenUsage
import com.correx.testing.fixtures.EventFixtures.stored
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class InferenceReducerTest {
private lateinit var reducer: InferenceReducer
@BeforeEach
fun setup() {
reducer = DefaultInferenceReducer()
}
@Test
fun `should start a new record on InferenceStartedEvent`() {
val initialState = InferenceState(emptyList())
val event = stored(
payload = InferenceStartedEvent(
requestId = InferenceRequestId("req1"),
sessionId = SessionId("sess1"),
stageId = StageId("stageA"),
providerId = ProviderId("providerX"),
),
)
val newState = reducer.reduce(initialState, event)
assertEquals(1, newState.records.size)
val record = newState.records.first()
assertEquals(InferenceStatus.STARTED, record.status)
assertEquals("req1", record.requestId.value)
}
@Test
fun `should complete a record on InferenceCompletedEvent`() {
val startedRecord = InferenceRecord(
requestId = InferenceRequestId("req1"),
sessionId = SessionId("sess1"),
stageId = StageId("stageA"),
providerId = ProviderId("providerX"),
status = InferenceStatus.STARTED,
)
val initialState = InferenceState(listOf(startedRecord))
val event = stored(
payload = InferenceCompletedEvent(
requestId = InferenceRequestId("req1"),
sessionId = SessionId("sess1"),
stageId = StageId("stageA"),
providerId = ProviderId("providerX"),
tokensUsed = TokenUsage(50, 50),
latencyMs = 500L,
),
)
val newState = reducer.reduce(initialState, event)
assertEquals(1, newState.records.size)
val record = newState.records.first()
assertEquals(InferenceStatus.COMPLETED, record.status)
assertEquals(100, record.tokensUsed?.totalTokens)
assertEquals(500L, record.latencyMs)
}
@Test
fun `should fail a record on InferenceFailedEvent`() {
val startedRecord = InferenceRecord(
requestId = InferenceRequestId("req1"),
sessionId = SessionId("sess1"),
stageId = StageId("stageA"),
providerId = ProviderId("providerX"),
status = InferenceStatus.STARTED,
)
val initialState = InferenceState(listOf(startedRecord))
val event = stored(
payload = InferenceFailedEvent(
requestId = InferenceRequestId("req1"),
sessionId = SessionId("sess1"),
stageId = StageId("stageA"),
providerId = ProviderId("providerX"),
reason = "Network failure",
),
)
val newState = reducer.reduce(initialState, event)
assertEquals(1, newState.records.size)
val record = newState.records.first()
assertEquals(InferenceStatus.FAILED, record.status)
assertEquals("Network failure", record.failureReason)
}
@Test
fun `should timeout a record on InferenceTimeoutEvent`() {
val startedRecord = InferenceRecord(
requestId = InferenceRequestId("req1"),
sessionId = SessionId("sess1"),
stageId = StageId("stageA"),
providerId = ProviderId("providerX"),
status = InferenceStatus.STARTED,
)
val initialState = InferenceState(listOf(startedRecord))
val event = stored(
payload = InferenceTimeoutEvent(
requestId = InferenceRequestId("req1"),
sessionId = SessionId("sess1"),
stageId = StageId("stageA"),
providerId = ProviderId("providerX"),
timeoutMs = 10000L,
),
)
val newState = reducer.reduce(initialState, event)
assertEquals(1, newState.records.size)
val record = newState.records.first()
assertEquals(InferenceStatus.TIMED_OUT, record.status)
assertEquals(10000L, record.latencyMs)
}
@Test
fun `should pass state unchanged on unrelated events`() {
val startedRecord = InferenceRecord(
requestId = InferenceRequestId("req1"),
sessionId = SessionId("sess1"),
stageId = StageId("stageA"),
providerId = ProviderId("providerX"),
status = InferenceStatus.STARTED,
)
val initialState = InferenceState(listOf(startedRecord))
val event = stored(
payload = ModelLoadedEvent(
sessionId = SessionId("sess1"),
providerId = ProviderId("providerX"),
modelId = "model",
),
)
val newState = reducer.reduce(initialState, event)
assertEquals(1, newState.records.size)
assertEquals(InferenceStatus.STARTED, newState.records.first().status)
}
@Test
fun `should not modify record if requestId does not match`() {
val startedRecord = InferenceRecord(
requestId = InferenceRequestId("req1"),
sessionId = SessionId("sess1"),
stageId = StageId("stageA"),
providerId = ProviderId("providerX"),
status = InferenceStatus.STARTED,
)
val initialState = InferenceState(listOf(startedRecord))
// Event for a different request
val event = stored(
payload = InferenceCompletedEvent(
requestId = InferenceRequestId("req2"),
sessionId = SessionId("sess1"),
stageId = StageId("stageA"),
providerId = ProviderId("providerX"),
tokensUsed = TokenUsage(50, 50),
latencyMs = 500L,
),
)
val newState = reducer.reduce(initialState, event)
// The record should remain unchanged
assertEquals(1, newState.records.size)
assertEquals(InferenceStatus.STARTED, newState.records.first().status)
}
}
@@ -0,0 +1,112 @@
import com.correx.core.events.events.OrchestrationPausedEvent
import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.RetryAttemptedEvent
import com.correx.core.events.events.WorkflowCompletedEvent
import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.events.events.WorkflowStartedEvent
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.orchestration.OrchestrationStatus
import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer
import com.correx.core.kernel.orchestration.OrchestrationProjector
import com.correx.testing.fixtures.EventFixtures.stored
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.Test
class OrchestrationProjectorTest {
private val reducer = DefaultOrchestrationReducer()
private val projector = OrchestrationProjector(reducer)
private val sessionId = SessionId("s1")
private val stageId = StageId("stage-1")
@Test
fun `should initialize orchestration with IDLE status`() {
val initialState = projector.initial()
assertEquals(OrchestrationStatus.IDLE, initialState.status)
}
@Test
fun `should set RUNNING on WorkflowStartedEvent`() {
val initialState = projector.initial()
val started =
projector.apply(initialState, stored(payload = WorkflowStartedEvent(sessionId, stageId)))
assertEquals(OrchestrationStatus.RUNNING, started.status)
}
@Test
fun `should set RUNNING after resume on pause`() {
val initialState = projector.initial()
val started =
projector.apply(initialState, stored(payload = WorkflowStartedEvent(sessionId, stageId)))
assertEquals(OrchestrationStatus.RUNNING, started.status)
val paused =
projector.apply(started, stored(payload = OrchestrationPausedEvent(sessionId, stageId, "Tool approval")))
assertEquals(OrchestrationStatus.PAUSED, paused.status)
assertTrue(paused.pendingApproval)
val resumed =
projector.apply(paused, stored(payload = OrchestrationResumedEvent(sessionId, stageId)))
assertEquals(OrchestrationStatus.RUNNING, resumed.status)
assertFalse(resumed.pendingApproval)
}
@Test
fun `should set FAILED on WorkflowFailedEvent`() {
val initialState = projector.initial()
val started =
projector.apply(initialState, stored(payload = WorkflowStartedEvent(sessionId, stageId)))
val failed =
projector.apply(
started,
stored(payload = WorkflowFailedEvent(sessionId, stageId, "Something went wrong", false)),
)
assertEquals(OrchestrationStatus.FAILED, failed.status)
}
@Test
fun `same events produce same result`() {
val initialState = projector.initial()
val events = listOf(
stored(payload = WorkflowStartedEvent(sessionId, stageId)),
stored(payload = WorkflowFailedEvent(sessionId, stageId, "Something went wrong", false)),
stored(
payload = RetryAttemptedEvent(
sessionId,
stageId,
attemptNumber = 1,
maxAttempts = 3,
failureReason = "Something went wrong",
),
),
stored(payload = WorkflowCompletedEvent(sessionId, stageId, 1)),
)
val result1 = events.fold(initialState) { state, event ->
projector.apply(state, event)
}
val result2 = events.fold(initialState) { state, event ->
projector.apply(state, event)
}
assertEquals(result1, result2)
}
@Test
fun `full pause-resume lifecycle`() {
val s0 = projector.initial()
val s1 = projector.apply(s0, stored(payload = WorkflowStartedEvent(sessionId, stageId)))
val s2 =
projector.apply(s1, stored(payload = OrchestrationPausedEvent(sessionId, stageId, "approval required")))
val s3 = projector.apply(s2, stored(payload = OrchestrationResumedEvent(sessionId, stageId)))
val s4 = projector.apply(s3, stored(payload = WorkflowCompletedEvent(sessionId, stageId, 1)))
assertEquals(OrchestrationStatus.RUNNING, s1.status)
assertEquals(OrchestrationStatus.PAUSED, s2.status)
assertTrue(s2.pendingApproval)
assertEquals(OrchestrationStatus.RUNNING, s3.status)
assertFalse(s3.pendingApproval)
assertEquals(OrchestrationStatus.COMPLETED, s4.status)
}
}
@@ -0,0 +1,146 @@
import com.correx.core.events.events.OrchestrationPausedEvent
import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.RetryAttemptedEvent
import com.correx.core.events.events.WorkflowCompletedEvent
import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.events.events.WorkflowStartedEvent
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.orchestration.OrchestrationState
import com.correx.core.events.orchestration.OrchestrationStatus
import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer
import com.correx.testing.fixtures.EventFixtures.stored
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class OrchestrationReducerTest {
private val reducer = DefaultOrchestrationReducer()
private val sessionId = SessionId("s1")
private val stageId = StageId("stage-1")
private val state = OrchestrationState(stageId)
@Test
fun `WorkflowStartedEvent sets status to RUNNING`() {
val state = reducer.reduce(
state,
stored(payload = WorkflowStartedEvent(sessionId, stageId)),
)
assertEquals(OrchestrationStatus.RUNNING, state.status)
assertEquals(stageId, state.currentStageId)
}
@Test
fun `WorkflowFailedEvent sets status to FAILED and failure reason`() {
val started = reducer.reduce(
state,
stored(payload = WorkflowStartedEvent(sessionId, stageId)),
)
val failed = reducer.reduce(
started,
stored(payload = WorkflowFailedEvent(sessionId, stageId, "Something went wrong", false)),
)
assertFalse(failed.failureReason.isNullOrBlank())
assertEquals(OrchestrationStatus.FAILED, failed.status)
}
@Test
fun `WorkflowCompletedEvent sets status to COMPLETED`() {
val started = reducer.reduce(
state,
stored(payload = WorkflowStartedEvent(sessionId, stageId)),
)
val completed = reducer.reduce(
started,
stored(payload = WorkflowCompletedEvent(sessionId, stageId, 1)),
)
assertTrue(completed.failureReason.isNullOrBlank())
assertEquals(OrchestrationStatus.COMPLETED, completed.status)
assertEquals(stageId, completed.currentStageId)
}
@Test
fun `OrchestrationPausedEvent sets status to PAUSED with reason and pending approval`() {
val started = reducer.reduce(
state,
stored(payload = WorkflowStartedEvent(sessionId, stageId)),
)
val paused = reducer.reduce(
started,
stored(payload = OrchestrationPausedEvent(sessionId, stageId, "Requires user approval")),
)
assertFalse(paused.pauseReason.isNullOrBlank())
assertEquals(OrchestrationStatus.PAUSED, paused.status)
assertTrue(paused.pendingApproval)
}
@Test
fun `OrchestrationResumedEvent sets status to RUNNING with null reason and no pending approval`() {
val started = reducer.reduce(
state,
stored(payload = WorkflowStartedEvent(sessionId, stageId)),
)
val paused = reducer.reduce(
started,
stored(payload = OrchestrationPausedEvent(sessionId, stageId, "Requires user approval")),
)
val resumed = reducer.reduce(
paused,
stored(payload = OrchestrationResumedEvent(sessionId, stageId)),
)
assertTrue(resumed.pauseReason.isNullOrBlank())
assertEquals(OrchestrationStatus.RUNNING, resumed.status)
assertFalse(resumed.pendingApproval)
}
@Test
fun `RetryAttemptedEvent sets status to RUNNING with null reason and no pending approval`() {
val started = reducer.reduce(
state,
stored(payload = WorkflowStartedEvent(sessionId, stageId)),
)
val failed = reducer.reduce(
started,
stored(payload = WorkflowFailedEvent(sessionId, stageId, "Something went wrong", false)),
)
val retrying1 = reducer.reduce(
failed,
stored(payload = RetryAttemptedEvent(sessionId, stageId, 1, 3, "Something went wrong")),
)
val retrying2 = reducer.reduce(
retrying1,
stored(payload = RetryAttemptedEvent(sessionId, stageId, 2, 3, "Something went wrong")),
)
val retrying3 = reducer.reduce(
retrying2,
stored(payload = RetryAttemptedEvent(sessionId, stageId, 3, 3, "Something went wrong")),
)
assertTrue(retrying1.retryCount < retrying2.retryCount && retrying2.retryCount < retrying3.retryCount)
assertEquals(OrchestrationStatus.RUNNING, retrying1.status)
assertNull(retrying1.failureReason)
}
@Test
fun `unrelated event does nothing`() {
val started = reducer.reduce(
state,
stored(payload = WorkflowStartedEvent(sessionId, stageId)),
)
val unrelated = reducer.reduce(started, stored())
assertEquals(started, unrelated)
}
}
@@ -0,0 +1,144 @@
import com.correx.core.events.events.SessionCompletedEvent
import com.correx.core.events.events.SessionPausedEvent
import com.correx.core.events.events.SessionResumedEvent
import com.correx.core.events.events.SessionStartedEvent
import com.correx.core.events.events.StageCompletedEvent
import com.correx.core.events.events.StageFailedEvent
import com.correx.core.events.events.StageStartedEvent
import com.correx.core.events.events.TransitionExecutedEvent
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.TransitionId
import com.correx.core.sessions.DefaultSessionReducer
import com.correx.core.sessions.SessionProjector
import com.correx.core.sessions.SessionStatus
import com.correx.testing.fixtures.EventFixtures.stored
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class SessionProjectorTest {
private val projector = SessionProjector(DefaultSessionReducer())
@Test
fun `full lifecycle transitions correctly`() {
var state = projector.initial()
val events = listOf(
stored(eventId = EventId("e1"), payload = SessionStartedEvent(SessionId("s1"))),
stored(eventId = EventId("e2"), payload = SessionPausedEvent(SessionId("s1"))),
stored(eventId = EventId("e3"), payload = SessionResumedEvent(SessionId("s1"))),
stored(eventId = EventId("e4"), payload = SessionCompletedEvent(SessionId("s1")))
)
events.forEach {
state = projector.apply(state, it)
}
assertEquals(SessionStatus.COMPLETED, state.status)
}
@Test
fun `failed stage transitions session to FAILED`() {
var state = projector.initial()
val events = listOf(
stored(
eventId = EventId("e1"),
payload = SessionStartedEvent(SessionId("s1"))
),
stored(
eventId = EventId("e2"),
payload = StageStartedEvent(
sessionId = SessionId("s1"),
stageId = StageId("draft"),
transitionId = TransitionId("t1")
)
),
stored(
eventId = EventId("e3"),
payload = StageFailedEvent(
sessionId = SessionId("s1"),
stageId = StageId("draft"),
transitionId = TransitionId("t1"),
reason = "boom"
)
)
)
events.forEach {
state = projector.apply(state, it)
}
assertEquals(SessionStatus.FAILED, state.status)
}
@Test
fun `transition events keep session ACTIVE`() {
var state = projector.initial()
val events = listOf(
stored(
eventId = EventId("e1"),
payload = SessionStartedEvent(SessionId("s1"))
),
stored(
eventId = EventId("e2"),
payload = TransitionExecutedEvent(
sessionId = SessionId("s1"),
from = StageId("a"),
to = StageId("b"),
transitionId = TransitionId("t1")
)
),
stored(
eventId = EventId("e3"),
payload = StageStartedEvent(
sessionId = SessionId("s1"),
stageId = StageId("b"),
transitionId = TransitionId("t1")
)
),
stored(
eventId = EventId("e4"),
payload = StageCompletedEvent(
sessionId = SessionId("s1"),
stageId = StageId("b"),
transitionId = TransitionId("t1")
)
)
)
events.forEach {
state = projector.apply(state, it)
}
assertEquals(SessionStatus.ACTIVE, state.status)
}
@Test
fun `projection replay is deterministic`() {
val events = listOf(
stored(
eventId = EventId("e1"),
payload = SessionStartedEvent(SessionId("s1"))
),
stored(
eventId = EventId("e2"),
payload = SessionCompletedEvent(SessionId("s1"))
)
)
val state1 = events.fold(projector.initial()) { state, event ->
projector.apply(state, event)
}
val state2 = events.fold(projector.initial()) { state, event ->
projector.apply(state, event)
}
assertEquals(state1, state2)
}
}
+15
View File
@@ -0,0 +1,15 @@
plugins {
id 'java-library'
id 'org.jetbrains.kotlin.jvm'
id 'org.jetbrains.kotlin.plugin.serialization'
}
dependencies {
testImplementation(project(":core:events"))
testImplementation(project(":core:sessions"))
testImplementation(project(":core:transitions"))
testImplementation(project(":core:validation"))
testImplementation(project(":core:approvals"))
testImplementation(project(":testing:fixtures"))
testImplementation(project(":infrastructure:persistence"))
}
@@ -0,0 +1,3 @@
package com.correx.testing.replay
object Module
@@ -0,0 +1,59 @@
import com.correx.core.approvals.Tier
import com.correx.core.approvals.domain.DefaultApprovalEngine
import com.correx.core.approvals.model.ApprovalContext
import com.correx.core.approvals.model.ApprovalGrant
import com.correx.core.approvals.model.ApprovalScopeIdentity
import com.correx.core.approvals.GrantScope
import com.correx.core.events.types.ApprovalDecisionId
import com.correx.core.events.types.GrantId
import com.correx.core.events.types.SessionId
import com.correx.core.sessions.ApprovalMode
import com.correx.testing.fixtures.request
import kotlinx.datetime.Instant
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class ApprovalReplayTest {
@Test
fun `replaying same request with same inputs yields identical decision`() {
val engine1 = DefaultApprovalEngine()
val engine2 = DefaultApprovalEngine()
val req = request("req1", Tier.T2)
val ctx = ApprovalContext(
ApprovalScopeIdentity(SessionId("s1"), null, null),
ApprovalMode.AUTO
)
val grants = listOf(
ApprovalGrant(
GrantId("g1"),
GrantScope.SESSION,
setOf(Tier.T2),
"reason",
Instant.parse("2026-01-01T00:00:00Z")
)
)
val now = Instant.parse("2026-01-01T00:00:01Z")
val d1 = engine1.evaluate(req, ctx, grants, now)
val d2 = engine2.evaluate(req, ctx, grants, now)
assertEquals(d1, d2)
}
@Test
fun `decision ID determinism holds over multiple replays`() {
val engine = DefaultApprovalEngine()
val req = request("fixed", Tier.T3)
val ctx = ApprovalContext(
ApprovalScopeIdentity(SessionId("s"), null, null),
ApprovalMode.PROMPT
)
val now = Instant.parse("2026-01-01T00:00:01Z")
val d1 = engine.evaluate(req, ctx, emptyList(), now)
val d2 = engine.evaluate(req, ctx, emptyList(), now)
assertEquals(d1.id, d2.id)
assertEquals(ApprovalDecisionId("decision:fixed"), d1.id)
}
}
@@ -0,0 +1,24 @@
import com.correx.core.events.types.SessionId
import com.correx.core.sessions.DefaultSessionReducer
import com.correx.core.sessions.SessionProjector
import com.correx.core.sessions.SessionStatus
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
import com.correx.infrastructure.persistence.InMemoryEventStore
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class SessionEmptyReplayTest {
@Test
fun `empty session returns initial state`() {
val store = InMemoryEventStore()
val replayer = DefaultEventReplayer(
store,
SessionProjector(DefaultSessionReducer())
)
val state = replayer.rebuild(SessionId("missing"))
assertEquals(SessionStatus.CREATED, state.status)
}
}
@@ -0,0 +1,57 @@
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.SessionCompletedEvent
import com.correx.core.events.events.SessionPausedEvent
import com.correx.core.events.events.SessionResumedEvent
import com.correx.core.events.events.SessionStartedEvent
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import com.correx.core.sessions.DefaultSessionReducer
import com.correx.core.sessions.SessionProjector
import com.correx.core.sessions.SessionStatus
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
import com.correx.infrastructure.persistence.InMemoryEventStore
import kotlinx.datetime.Clock
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class SessionReplayTest {
private val store = InMemoryEventStore()
private val projector = SessionProjector(DefaultSessionReducer())
private val replayer = DefaultEventReplayer(store, projector)
@Test
fun `rebuild session from event stream`() {
val sessionId = SessionId("s1")
val metadataToPayload = mapOf<EventMetadata, EventPayload>(
EventMetadata(EventId("start"), sessionId, Clock.System.now(), 1, null, null) to SessionStartedEvent(
sessionId
),
EventMetadata(EventId("paused"), sessionId, Clock.System.now(), 1, null, null) to SessionPausedEvent(
sessionId
),
EventMetadata(EventId("resumed"), sessionId, Clock.System.now(), 1, null, null) to SessionResumedEvent(
sessionId
),
EventMetadata(
EventId("completed"),
sessionId,
Clock.System.now(),
1,
null,
null
) to SessionCompletedEvent(sessionId),
)
metadataToPayload.map { (meta, payload) ->
store.append(NewEvent(meta, payload))
}
val state = replayer.rebuild(sessionId)
assertEquals(SessionStatus.COMPLETED, state.status)
}
}
@@ -0,0 +1,209 @@
import com.correx.core.events.events.SessionCompletedEvent
import com.correx.core.events.events.SessionStartedEvent
import com.correx.core.events.events.StageCompletedEvent
import com.correx.core.events.events.StageFailedEvent
import com.correx.core.events.events.StageStartedEvent
import com.correx.core.events.events.TransitionExecutedEvent
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.TransitionId
import com.correx.core.sessions.DefaultSessionReducer
import com.correx.core.sessions.SessionProjector
import com.correx.core.sessions.SessionStatus
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
import com.correx.infrastructure.persistence.InMemoryEventStore
import com.correx.testing.fixtures.EventFixtures.newEvent
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class TransitionReplayIntegrationTest {
private val sessionId = SessionId("session-1")
@Test
fun `workflow replay reconstructs COMPLETED session`() {
val store = InMemoryEventStore()
store.appendAll(
listOf(
newEvent(
sessionId = sessionId,
eventId = EventId("event-1"),
payload = SessionStartedEvent(sessionId)
),
newEvent(
sessionId = sessionId,
eventId = EventId("event-2"),
payload = TransitionExecutedEvent(
sessionId = sessionId,
from = StageId("draft"),
to = StageId("review"),
transitionId = TransitionId("transition-1")
)
),
newEvent(
sessionId = sessionId,
eventId = EventId("event-3"),
payload = StageStartedEvent(
sessionId = sessionId,
stageId = StageId("review"),
transitionId = TransitionId("transition-1")
)
),
newEvent(
sessionId = sessionId,
eventId = EventId("event-4"),
payload = StageCompletedEvent(
sessionId = sessionId,
stageId = StageId("review"),
transitionId = TransitionId("transition-1")
)
),
newEvent(
sessionId = sessionId,
eventId = EventId("event-5"),
payload = SessionCompletedEvent(sessionId)
)
)
)
val replayer = DefaultEventReplayer(
store = store,
projection = SessionProjector(
reducer = DefaultSessionReducer()
)
)
val state = replayer.rebuild(sessionId)
assertEquals(
SessionStatus.COMPLETED,
state.status
)
}
@Test
fun `workflow replay reconstructs FAILED session`() {
val store = InMemoryEventStore()
store.appendAll(
listOf(
newEvent(
sessionId = sessionId,
eventId = EventId("event-1"),
payload = SessionStartedEvent(sessionId)
),
newEvent(
sessionId = sessionId,
eventId = EventId("event-2"),
payload = TransitionExecutedEvent(
sessionId = sessionId,
from = StageId("draft"),
to = StageId("review"),
transitionId = TransitionId("transition-1")
)
),
newEvent(
sessionId = sessionId,
eventId = EventId("event-3"),
payload = StageStartedEvent(
sessionId = sessionId,
stageId = StageId("review"),
transitionId = TransitionId("transition-1")
)
),
newEvent(
sessionId = sessionId,
eventId = EventId("event-4"),
payload = StageFailedEvent(
sessionId = sessionId,
stageId = StageId("review"),
transitionId = TransitionId("transition-1"),
reason = "validation failed"
)
)
)
)
val replayer = DefaultEventReplayer(
store = store,
projection = SessionProjector(
reducer = DefaultSessionReducer()
)
)
val state = replayer.rebuild(sessionId)
assertEquals(
SessionStatus.FAILED,
state.status
)
}
@Test
fun `replay is deterministic for identical event stream`() {
val events = listOf(
newEvent(
sessionId = sessionId,
eventId = EventId("event-1"),
payload = SessionStartedEvent(sessionId)
),
newEvent(
sessionId = sessionId,
eventId = EventId("event-2"),
payload = TransitionExecutedEvent(
sessionId = sessionId,
from = StageId("draft"),
to = StageId("review"),
transitionId = TransitionId("transition-1")
)
),
newEvent(
sessionId = sessionId,
eventId = EventId("event-3"),
payload = StageStartedEvent(
sessionId = sessionId,
stageId = StageId("review"),
transitionId = TransitionId("transition-1")
)
),
newEvent(
sessionId = sessionId,
eventId = EventId("event-4"),
payload = StageCompletedEvent(
sessionId = sessionId,
stageId = StageId("review"),
transitionId = TransitionId("transition-1")
)
)
)
val store1 = InMemoryEventStore()
val store2 = InMemoryEventStore()
store1.appendAll(events)
store2.appendAll(events)
val projection = SessionProjector(
reducer = DefaultSessionReducer()
)
val replayer1 = DefaultEventReplayer(
store = store1,
projection = projection
)
val replayer2 = DefaultEventReplayer(
store = store2,
projection = projection
)
val state1 = replayer1.rebuild(sessionId)
val state2 = replayer2.rebuild(sessionId)
assertEquals(state1, state2)
}
}
@@ -0,0 +1,32 @@
import com.correx.core.transitions.resolution.TransitionOrdering
import com.correx.core.validation.graph.GraphValidator
import com.correx.core.validation.model.ValidationContext
import com.correx.core.validation.pipeline.ValidationPipeline
import com.correx.core.validation.transition.TransitionValidator
import com.correx.testing.fixtures.WorkflowFixtures
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class ValidationReplayTest {
@Test
fun `validation must be replay deterministic`() {
val graph = WorkflowFixtures.simpleGraph()
val context = ValidationContext(graph)
val pipeline = ValidationPipeline(
listOf(
GraphValidator(),
TransitionValidator(TransitionOrdering.comparator)
)
)
val results = (1..10).map {
pipeline.validate(context)
}
assertTrue(results.distinct().size == 1)
}
}
+11
View File
@@ -0,0 +1,11 @@
plugins {
id 'java-library'
id 'org.jetbrains.kotlin.jvm'
id 'org.jetbrains.kotlin.plugin.serialization'
}
dependencies {
testImplementation(project(":core:transitions"))
testImplementation(project(":core:events"))
testImplementation(project(":core:inference"))
}
@@ -0,0 +1,225 @@
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.TransitionId
import com.correx.core.transitions.evaluation.EvaluationContext
import com.correx.core.transitions.evaluation.TransitionConditionEvaluator
import com.correx.core.transitions.graph.StageConfig
import com.correx.core.transitions.graph.TransitionCondition
import com.correx.core.transitions.graph.TransitionEdge
import com.correx.core.transitions.graph.WorkflowGraph
import com.correx.core.transitions.resolution.DefaultTransitionResolver
import com.correx.core.transitions.resolution.TransitionDecision
import com.correx.core.transitions.resolution.TransitionResolver
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertInstanceOf
class DefaultTransitionResolverTest {
private val evaluator = object : TransitionConditionEvaluator {
override fun evaluate(
condition: TransitionCondition,
context: EvaluationContext
): Boolean {
return condition.evaluate(context)
}
}
private val resolver: TransitionResolver = DefaultTransitionResolver(evaluator)
@Test
fun `returns NoMatch when no outgoing transitions exist for current stage`() {
val graph = graph(
start = "stage-a",
transitions = setOf(
edge(
id = "t1",
from = "stage-a",
to = "stage-b",
condition = alwaysTrue()
)
),
stages = setOf("stage-a", "stage-b")
)
val result = resolver.resolve(
graph = graph,
context = context(currentStage = "stage-b")
)
assertEquals(
TransitionDecision.NoMatch,
result
)
}
@Test
fun `returns Stay when all conditions evaluate to false`() {
val graph = graph(
start = "stage-a",
transitions = setOf(
edge(
id = "t1",
from = "stage-a",
to = "stage-b",
condition = alwaysFalse()
)
)
)
val result = resolver.resolve(
graph = graph,
context = context(currentStage = "stage-a")
)
assertEquals(
TransitionDecision.Stay,
result
)
}
@Test
fun `returns matching transition when condition evaluates to true`() {
val graph = graph(
start = "stage-a",
transitions = setOf(
edge(
id = "t1",
from = "stage-a",
to = "stage-b",
condition = alwaysTrue()
)
)
)
val result = resolver.resolve(
graph = graph,
context = context(currentStage = "stage-a")
)
val move = assertInstanceOf<TransitionDecision.Move>(result)
assertEquals(
TransitionId("t1"),
move.transitionId
)
assertEquals(
StageId("stage-b"),
move.to
)
}
@Test
fun `first matching transition wins deterministically`() {
val graph = graph(
start = "stage-a",
transitions = setOf(
edge(
id = "t2",
from = "stage-a",
to = "stage-c",
condition = alwaysTrue()
),
edge(
id = "t1",
from = "stage-a",
to = "stage-b",
condition = alwaysTrue()
)
)
)
val result = resolver.resolve(
graph = graph,
context = context(currentStage = "stage-a")
)
val move = assertInstanceOf<TransitionDecision.Move>(result)
assertEquals(
TransitionId("t1"),
move.transitionId
)
assertEquals(
StageId("stage-b"),
move.to
)
}
@Test
fun `only transitions from current stage are evaluated`() {
val graph = graph(
start = "stage-a",
transitions = setOf(
edge(
id = "t1",
from = "stage-x",
to = "stage-y",
condition = alwaysTrue()
),
edge(
id = "t2",
from = "stage-a",
to = "stage-b",
condition = alwaysTrue()
)
)
)
val result = resolver.resolve(
graph = graph,
context = context(currentStage = "stage-a")
)
val move = assertInstanceOf<TransitionDecision.Move>(result)
assertEquals(
TransitionId("t2"),
move.transitionId
)
}
private fun edge(
id: String,
from: String,
to: String,
condition: TransitionCondition
): TransitionEdge {
return TransitionEdge(
id = TransitionId(id),
from = StageId(from),
to = StageId(to),
condition = condition
)
}
private fun graph(
start: String,
transitions: Set<TransitionEdge>,
stages: Set<String> = transitions.flatMap { listOf(it.from.value, it.to.value) }.toSet()
) = WorkflowGraph(
start = StageId(start),
stages = stages.associate { StageId(it) to StageConfig() },
transitions = transitions
)
private fun context(
currentSession: String = "s1",
currentStage: String,
): EvaluationContext {
return EvaluationContext(
sessionId = SessionId(currentSession),
currentStage = StageId(currentStage),
variables = emptyMap(),
)
}
private fun alwaysTrue() =
TransitionCondition { true }
@Suppress("UnusedPrivateMember")
private fun alwaysFalse() =
TransitionCondition { false }
}
@@ -0,0 +1,72 @@
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.StageStartedEvent
import com.correx.core.events.events.TransitionExecutedEvent
import com.correx.core.events.serialization.JsonEventSerializer
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.TransitionId
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class TransitionEventSerializationTest {
@Test
fun `StageStartedEvent serializes and deserializes`() {
val event = StageStartedEvent(
sessionId = SessionId("session-1"),
stageId = StageId("stage-a"),
transitionId = TransitionId("transition-a")
)
assertRoundTrip(event)
}
@Test
fun `StageCompletedEvent serializes and deserializes`() {
val event = StageCompletedEvent(
sessionId = SessionId("session-1"),
stageId = StageId("stage-a"),
transitionId = TransitionId("transition-a")
)
assertRoundTrip(event)
}
@Test
fun `StageFailedEvent serializes and deserializes`() {
val event = StageFailedEvent(
sessionId = SessionId("session-1"),
stageId = StageId("stage-a"),
transitionId = TransitionId("transition-a"),
reason = "boom"
)
assertRoundTrip(event)
}
@Test
fun `TransitionExecutedEvent serializes and deserializes`() {
val event = TransitionExecutedEvent(
sessionId = SessionId("session-1"),
from = StageId("stage-a"),
to = StageId("stage-b"),
transitionId = TransitionId("transition-a")
)
assertRoundTrip(event)
}
private inline fun <reified T : EventPayload> assertRoundTrip(
event: T
) {
val json = JsonEventSerializer()
val serialized = json.serialize(event)
val deserialized =
json.deserialize(serialized)
assertEquals(event, deserialized)
}
}