epic-12: after epic audit and init commit
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
plugins {
|
||||
id 'java-library'
|
||||
id 'org.jetbrains.kotlin.jvm'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:events"))
|
||||
implementation(project(":core: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
|
||||
)
|
||||
+31
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user