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
+10
View File
@@ -0,0 +1,10 @@
plugins {
id 'java-library'
id 'org.jetbrains.kotlin.jvm'
id 'org.jetbrains.kotlin.plugin.serialization'
}
dependencies {
implementation(project(":core:events"))
implementation(project(":core:context"))
}
@@ -0,0 +1,20 @@
package com.correx.core.inference
import kotlinx.serialization.Serializable
import kotlin.time.Duration
sealed class CancellationReason {
object UserRequested : CancellationReason()
object StageTimeout : CancellationReason()
object SessionCancelled : CancellationReason()
object ProviderEvicted : CancellationReason()
}
/**
* Records a deadline that has fired. Used by the harness to carry timeout context when
* cancelling an [InferenceCancellationToken] with [CancellationReason.StageTimeout].
* Produced by the harness only — providers must not construct this.
*/
@JvmInline
@Serializable
value class InferenceTimeout(val duration: Duration)
@@ -0,0 +1,47 @@
package com.correx.core.inference
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.StoredEvent
import com.correx.core.events.types.InferenceRequestId
class DefaultInferenceReducer : InferenceReducer {
override fun reduce(state: InferenceState, event: StoredEvent): InferenceState = when (val p = event.payload) {
is InferenceStartedEvent -> {
val newRecord = InferenceRecord(
requestId = p.requestId,
sessionId = p.sessionId,
stageId = p.stageId,
providerId = p.providerId,
status = InferenceStatus.STARTED,
)
state.copy(records = state.records + newRecord)
}
is InferenceCompletedEvent -> updateRecord(state, p.requestId) {
it.copy(status = InferenceStatus.COMPLETED, tokensUsed = p.tokensUsed, latencyMs = p.latencyMs)
}
is InferenceFailedEvent -> updateRecord(state, p.requestId) {
it.copy(status = InferenceStatus.FAILED, failureReason = p.reason)
}
is InferenceTimeoutEvent -> updateRecord(state, p.requestId) {
it.copy(status = InferenceStatus.TIMED_OUT, latencyMs = p.timeoutMs)
}
else -> state
}
private fun updateRecord(
state: InferenceState,
requestId: InferenceRequestId,
transform: (InferenceRecord) -> InferenceRecord,
): InferenceState {
val updated = state.records.map { if (it.requestId == requestId) transform(it) else it }
return state.copy(records = updated)
}
}
@@ -0,0 +1,60 @@
package com.correx.core.inference
import com.correx.core.events.types.ProviderId
import com.correx.core.events.types.StageId
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TimeMark
import kotlin.time.TimeSource
private data class HealthEntry(
val health: ProviderHealth,
val mark: TimeMark,
)
class DefaultInferenceRouter(
private val registry: ProviderRegistry,
private val strategy: RoutingStrategy,
private val cacheTtl: Duration = 5.seconds,
private val timeSource: TimeSource = TimeSource.Monotonic,
) : InferenceRouter {
private val cache = mutableMapOf<ProviderId, HealthEntry>()
private val locks = mutableMapOf<ProviderId, Mutex>()
private val mapMutex = Mutex()
private suspend fun lockFor(id: ProviderId): Mutex =
mapMutex.withLock { locks.getOrPut(id) { Mutex() } }
private suspend fun cachedHealth(provider: InferenceProvider): ProviderHealth {
val existing = mapMutex.withLock { cache[provider.id] }
if (existing != null && existing.mark.elapsedNow() < cacheTtl) return existing.health
return lockFor(provider.id).withLock {
// re-check after acquiring lock — another coroutine may have refreshed
val recheck = mapMutex.withLock { cache[provider.id] }
if (recheck != null && recheck.mark.elapsedNow() < cacheTtl) return@withLock recheck.health
val fresh = provider.healthCheck()
mapMutex.withLock { cache[provider.id] = HealthEntry(fresh, timeSource.markNow()) }
fresh
}
}
override suspend fun route(stageId: StageId, requiredCapabilities: Set<ModelCapability>): InferenceProvider {
val candidates = requiredCapabilities
.flatMap { registry.resolve(it) }
.distinctBy { it.id }
.ifEmpty { registry.listAll() }
val healthy = candidates.filter { cachedHealth(it) !is ProviderHealth.Unavailable }
val selected = strategy.select(healthy, requiredCapabilities)
// Post-selection re-check closes the TOCTOU window between initial filter and dispatch.
when (val postHealth = selected.healthCheck()) {
is ProviderHealth.Unavailable -> throw ProviderUnavailableException(selected.id, postHealth.reason)
else -> Unit
}
return selected
}
}
@@ -0,0 +1,17 @@
package com.correx.core.inference
import kotlinx.serialization.Serializable
@Serializable
sealed class FinishReason {
@Serializable
object Stop : FinishReason()
@Serializable
object Length : FinishReason()
@Serializable
object Timeout : FinishReason()
@Serializable
object Cancelled : FinishReason()
@Serializable
data class Error(val message: String) : FinishReason()
}
@@ -0,0 +1,16 @@
package com.correx.core.inference
import kotlinx.serialization.Serializable
/**
* All fields required for deterministic replay.
* Callers must fully specify config — no implicit defaults at runtime.
*/
@Serializable
data class GenerationConfig(
val temperature: Double,
val topP: Double,
val maxTokens: Int,
val stopSequences: List<String> = emptyList(),
val seed: Long? = null, // null = non-deterministic; set for replay
)
@@ -0,0 +1,31 @@
package com.correx.core.inference
/**
* Cancellation contract for inference calls.
*
* ## Coroutine contract
* Implementations of [InferenceProvider.infer] MUST honour cancellation cooperatively.
* Checkpoints are mandatory at:
* - before the HTTP/socket write
* - after each streamed chunk (if streaming)
* - before parsing the final response
*
* Use `kotlinx.coroutines.ensureActive()` at each checkpoint.
* Blocking calls must be wrapped with `withContext(Dispatchers.IO)` and must
* not suppress `CancellationException`.
*
* ## Timeout / token interaction
* Direction is one-way: when a deadline fires, the harness cancels this token (calling
* [cancel] with [CancellationReason.StageTimeout]). The reverse is NOT true — calling
* [cancel] on this token for any other reason MUST NOT retroactively record or signal a
* timeout. Only a fired [InferenceTimeout] deadline triggers [CancellationReason.StageTimeout].
*
* ## Event contract
* On cancellation, the provider MUST emit [InferenceTimeoutEvent] (for deadline
* exceeded) or allow the harness to emit [InferenceFailedEvent] with
* [CancellationReason] attached. The provider MUST NOT swallow the cancellation.
*/
interface InferenceCancellationToken {
val isCancelled: Boolean
fun cancel(reason: CancellationReason)
}
@@ -0,0 +1,15 @@
package com.correx.core.inference
import com.correx.core.events.events.StoredEvent
import com.correx.core.sessions.projections.Projection
class InferenceProjector(
private val reducer: InferenceReducer = DefaultInferenceReducer()
) : Projection<InferenceState> {
override fun initial(): InferenceState = InferenceState(emptyList())
override fun apply(
state: InferenceState,
event: StoredEvent
): InferenceState = reducer.reduce(state, event)
}
@@ -0,0 +1,20 @@
package com.correx.core.inference
import com.correx.core.events.types.ProviderId
interface InferenceProvider {
val id: ProviderId
val name: String
val tokenizer: Tokenizer
suspend fun infer(request: InferenceRequest): InferenceResponse
suspend fun healthCheck(): ProviderHealth
fun capabilities(): Set<CapabilityScore>
}
interface ProviderRegistry {
fun register(provider: InferenceProvider)
fun resolve(capability: ModelCapability): List<InferenceProvider> // ordered by score desc
fun listAll(): List<InferenceProvider>
suspend fun healthCheckAll(): Map<ProviderId, ProviderHealth>
}
@@ -0,0 +1,19 @@
package com.correx.core.inference
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 kotlinx.serialization.Serializable
@Serializable
data class InferenceRecord(
val requestId: InferenceRequestId,
val sessionId: SessionId,
val stageId: StageId,
val providerId: ProviderId,
val status: InferenceStatus,
val tokensUsed: TokenUsage? = null,
val latencyMs: Long? = null,
val failureReason: String? = null,
)
@@ -0,0 +1,8 @@
package com.correx.core.inference
import com.correx.core.events.events.StoredEvent
import com.correx.core.inference.InferenceState
interface InferenceReducer {
fun reduce(state: InferenceState, event: StoredEvent): InferenceState
}
@@ -0,0 +1,12 @@
package com.correx.core.inference
import com.correx.core.events.types.SessionId
import com.correx.core.sessions.projections.replay.EventReplayer
class InferenceRepository(
private val replayer: EventReplayer<InferenceState>
) {
fun getInferenceState(sessionId: SessionId): InferenceState {
return replayer.rebuild(sessionId)
}
}
@@ -0,0 +1,17 @@
package com.correx.core.inference
import com.correx.core.context.model.ContextPack
import com.correx.core.events.types.InferenceRequestId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import kotlinx.serialization.Serializable
@Serializable
data class InferenceRequest(
val requestId: InferenceRequestId,
val sessionId: SessionId,
val stageId: StageId,
val contextPack: ContextPack,
val generationConfig: GenerationConfig,
val timeout: InferenceTimeout? = null,
)
@@ -0,0 +1,13 @@
package com.correx.core.inference
import com.correx.core.events.types.InferenceRequestId
import kotlinx.serialization.Serializable
@Serializable
data class InferenceResponse(
val requestId: InferenceRequestId,
val text: String,
val finishReason: FinishReason,
val tokensUsed: TokenUsage,
val latencyMs: Long,
)
@@ -0,0 +1,44 @@
package com.correx.core.inference
import com.correx.core.events.types.StageId
/**
* Pure selection function — no I/O, no side effects.
* Operates on a snapshot of available providers.
*/
fun interface RoutingStrategy {
/**
* @throws NoEligibleProviderException if no candidate satisfies all required capabilities
*/
fun select(
candidates: List<InferenceProvider>,
requiredCapabilities: Set<ModelCapability>,
): InferenceProvider
}
interface InferenceRouter {
/**
* Resolves and selects a provider for the given stage.
* Performs a live health check and filters [ProviderHealth.Unavailable] providers
* before selection, so callers never receive a provider that is known-down at routing time.
* @throws NoEligibleProviderException if no healthy provider satisfies requirements
*/
suspend fun route(
stageId: StageId,
requiredCapabilities: Set<ModelCapability>,
): InferenceProvider
}
class NoEligibleProviderException(
stageId: StageId,
required: Set<ModelCapability>,
) : Exception(
"No provider satisfies capabilities $required for stage '${stageId.value}'",
)
class ProviderUnavailableException(
providerId: com.correx.core.events.types.ProviderId,
reason: String,
) : Exception(
"Provider '${providerId.value}' is unavailable: $reason",
)
@@ -0,0 +1,8 @@
package com.correx.core.inference
import kotlinx.serialization.Serializable
@Serializable
data class InferenceState(
val records: List<InferenceRecord> = emptyList()
)
@@ -0,0 +1,5 @@
package com.correx.core.inference
enum class InferenceStatus {
STARTED, COMPLETED, FAILED, TIMED_OUT
}
@@ -0,0 +1,27 @@
package com.correx.core.inference
import kotlinx.serialization.Serializable
@Serializable
sealed class ModelCapability {
@Serializable
object Coding : ModelCapability()
@Serializable
object ToolCalling : ModelCapability()
@Serializable
object Reasoning : ModelCapability()
@Serializable
object Summarization : ModelCapability()
@Serializable
object General : ModelCapability()
}
@Serializable
data class CapabilityScore(
val capability: ModelCapability,
val score: Double, // 0.0 1.0; higher = better
)
@@ -0,0 +1,11 @@
package com.correx.core.inference
/**
* Thrown when model loading fails during DefaultModelManager.load().
* No event is emitted on failure per event sourcing principles.
*/
class ModelLoadException(
override val message: String,
val modelId: String,
override val cause: Throwable? = null,
) : RuntimeException(message, cause)
@@ -0,0 +1,7 @@
package com.correx.core.inference
sealed class ProviderHealth {
object Healthy : ProviderHealth()
data class Degraded(val reason: String) : ProviderHealth()
data class Unavailable(val reason: String) : ProviderHealth()
}
@@ -0,0 +1,13 @@
package com.correx.core.inference
@JvmInline
value class Token(val id: Int)
/**
* Provider-owned. Tokenization is model-family-specific;
* two providers for the same capability may not share a tokenizer.
*/
interface Tokenizer {
suspend fun tokenize(text: String): List<Token>
suspend fun countTokens(text: String): Int
}