feat: correx-managed model lifecycle slice 2 — per-stage model selection

StageConfig.modelId; InferenceRouter gains a backward-compatible model-aware
route() overload (default ignores modelId, so static routers and test fakes are
unaffected). New ManagedInferenceRouter (infra commons) resolves a target model
per stage (stage.modelId > capability match > default) and ensureLoaded()s it
before routing, returning the live managed provider — its id changes with the
resident model, so there is no stale health cache to invalidate on swap.
ModelManager.ensureLoaded default delegates to the idempotent load(). Main wires
the managed router on the [[models]] path; the static path keeps DefaultInferenceRouter.

Plan: docs/plans/2026-05-31-model-lifecycle-management.md (slice 2 of 5).
This commit is contained in:
2026-06-01 11:35:51 +04:00
parent e45a626cc4
commit 382194b498
8 changed files with 240 additions and 2 deletions
@@ -0,0 +1,70 @@
package com.correx.infrastructure.inference.commons
import com.correx.core.events.types.StageId
import com.correx.core.inference.InferenceProvider
import com.correx.core.inference.InferenceRouter
import com.correx.core.inference.ModelCapability
import com.correx.core.inference.NoEligibleProviderException
/**
* An [InferenceRouter] that owns the local model lifecycle: it resolves a single target model
* per stage and ensures it is resident (via [ModelManager.ensureLoaded]) before returning the
* managed provider to run inference against.
*
* Resolution precedence (per the model-lifecycle spec):
* ```
* stage.modelId > capability match over configured models > defaultModelId
* ```
* The manual-swap pin (a higher-precedence override) layers in on top of this in a later slice.
*
* Because the returned provider is the live managed provider (its [InferenceProvider.id] changes
* with the resident model), there is no stale router-level health cache to invalidate on swap —
* selection always reflects the model the manager just made resident.
*/
class ManagedInferenceRouter(
private val manager: ModelManager,
descriptors: List<ModelDescriptor>,
private val defaultModelId: String,
) : InferenceRouter {
private val byId: Map<String, ModelDescriptor> = descriptors.associateBy { it.modelId }
private val ordered: List<ModelDescriptor> = descriptors
override suspend fun route(
stageId: StageId,
requiredCapabilities: Set<ModelCapability>,
): InferenceProvider = route(stageId, requiredCapabilities, null)
override suspend fun route(
stageId: StageId,
requiredCapabilities: Set<ModelCapability>,
modelId: String?,
): InferenceProvider {
val targetId = modelId
?: capabilityMatch(requiredCapabilities)
?: defaultModelId
val descriptor = byId[targetId]
?: throw NoEligibleProviderException(stageId, requiredCapabilities)
return manager.ensureLoaded(descriptor)
}
/**
* Selects the configured model whose declared capabilities cover every required capability,
* preferring the highest summed score over those required capabilities. Returns null when no
* capabilities are required or none of the models satisfy them (callers fall back to default).
*/
private fun capabilityMatch(required: Set<ModelCapability>): String? {
if (required.isEmpty()) return null
return ordered
.filter { descriptor ->
val declared = descriptor.capabilities.map { it.capability }.toSet()
declared.containsAll(required)
}
.maxByOrNull { descriptor ->
descriptor.capabilities
.filter { it.capability in required }
.sumOf { it.score }
}
?.modelId
}
}
@@ -16,6 +16,13 @@ interface ModelManager {
*/
suspend fun load(descriptor: ModelDescriptor): InferenceProvider
/**
* Ensures the model described by [descriptor] is the resident model, returning its provider.
* A no-op load if that model is already current; otherwise loads it (evicting the previous
* model). [load] is idempotent for the already-loaded model, so the default delegates to it.
*/
suspend fun ensureLoaded(descriptor: ModelDescriptor): InferenceProvider = load(descriptor)
/**
* Unloads the currently loaded model.
*