epic-12: after epic audit and init commit
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
plugins {
|
||||
id 'java-library'
|
||||
id 'org.jetbrains.kotlin.jvm'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':core:events')
|
||||
implementation project(':core:approvals')
|
||||
implementation project(':core:validation')
|
||||
implementation project(':core:inference')
|
||||
|
||||
testImplementation "org.junit.jupiter:junit-jupiter"
|
||||
testImplementation "org.jetbrains.kotlin:kotlin-test"
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.correx.core.risk
|
||||
|
||||
import com.correx.core.events.risk.RiskLevel
|
||||
import com.correx.core.events.risk.RiskSignal
|
||||
import com.correx.core.events.risk.RiskSummary
|
||||
import com.correx.core.inference.InferenceStatus
|
||||
import com.correx.core.validation.model.ValidationSeverity
|
||||
|
||||
class DefaultRiskAssessor : RiskAssessor {
|
||||
override fun assess(context: RiskContext): RiskSummary {
|
||||
val (report, state, inferenceState) = context
|
||||
val signals = mutableListOf<RiskSignal>()
|
||||
|
||||
val errorCount = report.sections.sumOf { s ->
|
||||
s.issues.count { it.severity == ValidationSeverity.ERROR }
|
||||
}
|
||||
if (errorCount > 0) {
|
||||
signals += RiskSignal.ValidationErrors(errorCount)
|
||||
}
|
||||
|
||||
report.sections.flatMap { it.issues }
|
||||
.firstOrNull { it.code.contains("CYCLE", ignoreCase = true) }
|
||||
?.let { signals += RiskSignal.CycleWithoutExit(it.code) }
|
||||
|
||||
state.retryPolicy?.let { policy ->
|
||||
if (state.retryCount >= policy.maxAttempts - 1) {
|
||||
signals += RiskSignal.RepeatedFailure(
|
||||
reason = state.failureReason ?: "unknown",
|
||||
count = state.retryCount,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
state.currentStageId?.let { stageId ->
|
||||
inferenceState.records
|
||||
.firstOrNull { it.stageId == stageId && it.status == InferenceStatus.TIMED_OUT }
|
||||
?.let { signals += RiskSignal.InferenceTimeout(it.latencyMs ?: 0L) }
|
||||
}
|
||||
|
||||
val level = signals.fold(RiskLevel.LOW) { acc, signal -> maxOf(acc, signal.toRiskLevel()) }
|
||||
|
||||
return RiskSummary(
|
||||
level = level,
|
||||
signals = signals,
|
||||
recommendedAction = level.toRiskAction(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.correx.core.risk
|
||||
|
||||
import com.correx.core.events.risk.RiskAction
|
||||
import com.correx.core.events.risk.RiskLevel
|
||||
import com.correx.core.events.risk.RiskSummary
|
||||
|
||||
/**
|
||||
* No-op implementation of [RiskAssessor] used during deterministic replay.
|
||||
* Replay reconstructs approval decisions from the event log (ApprovalGrantedEvent /
|
||||
* ApprovalDeniedEvent) — live risk assessment must never run on the replay path.
|
||||
*/
|
||||
class NoOpRiskAssessor : RiskAssessor {
|
||||
override fun assess(context: RiskContext): RiskSummary = RiskSummary(
|
||||
level = RiskLevel.LOW,
|
||||
signals = emptyList(),
|
||||
recommendedAction = RiskAction.PROCEED,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.correx.core.risk
|
||||
|
||||
import com.correx.core.events.risk.RiskSummary
|
||||
|
||||
interface RiskAssessor {
|
||||
fun assess(context: RiskContext): RiskSummary
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.correx.core.risk
|
||||
|
||||
import com.correx.core.events.orchestration.OrchestrationState
|
||||
import com.correx.core.inference.InferenceState
|
||||
import com.correx.core.validation.model.ValidationReport
|
||||
|
||||
/**
|
||||
* Immutable snapshot of all signals available for risk assessment at a given decision point.
|
||||
*
|
||||
* [validationReport] — issues raised by the validation pipeline for the current stage.
|
||||
* [orchestrationState] — current orchestration state, including retry count, failure reason,
|
||||
* and retry policy. Carries execution-level signals that previously had no path into risk
|
||||
* evaluation (e.g. [OrchestrationState.retryCount], [OrchestrationState.retryPolicy]).
|
||||
* [inferenceState] — inference history, used to detect timeouts for the current stage.
|
||||
*
|
||||
* Note: [StageOutcome] lives in core:kernel which depends on core:risk, so it cannot be
|
||||
* embedded here directly. The relevant execution signals it carries (retry count, failure
|
||||
* reason) are proxied through [orchestrationState], which is populated from config before
|
||||
* the first assessment call.
|
||||
*/
|
||||
data class RiskContext(
|
||||
val validationReport: ValidationReport,
|
||||
val orchestrationState: OrchestrationState,
|
||||
val inferenceState: InferenceState,
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.correx.core.risk
|
||||
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.events.risk.RiskAction
|
||||
import com.correx.core.events.risk.RiskLevel
|
||||
import com.correx.core.events.risk.RiskSignal
|
||||
|
||||
fun RiskLevel.toApprovalTier(): Tier = when (this) {
|
||||
RiskLevel.LOW -> Tier.T1
|
||||
RiskLevel.MEDIUM -> Tier.T2
|
||||
RiskLevel.HIGH -> Tier.T3
|
||||
RiskLevel.CRITICAL -> Tier.T4
|
||||
}
|
||||
|
||||
internal fun RiskLevel.toRiskAction(): RiskAction = when (this) {
|
||||
RiskLevel.LOW -> RiskAction.PROCEED
|
||||
RiskLevel.MEDIUM -> RiskAction.PROMPT_USER
|
||||
RiskLevel.HIGH -> RiskAction.PROMPT_USER
|
||||
RiskLevel.CRITICAL -> RiskAction.BLOCK
|
||||
}
|
||||
|
||||
internal fun RiskSignal.toRiskLevel(): RiskLevel = when (this) {
|
||||
is RiskSignal.ValidationErrors -> RiskLevel.MEDIUM
|
||||
is RiskSignal.CycleWithoutExit -> RiskLevel.MEDIUM
|
||||
is RiskSignal.InferenceTimeout -> RiskLevel.MEDIUM
|
||||
is RiskSignal.RepeatedFailure -> RiskLevel.HIGH
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package com.correx.core.risk
|
||||
|
||||
import com.correx.core.events.execution.RetryPolicy
|
||||
import com.correx.core.events.orchestration.OrchestrationState
|
||||
import com.correx.core.events.orchestration.OrchestrationStatus
|
||||
import com.correx.core.events.risk.RiskAction
|
||||
import com.correx.core.events.risk.RiskLevel
|
||||
import com.correx.core.events.risk.RiskSignal
|
||||
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.InferenceRecord
|
||||
import com.correx.core.inference.InferenceState
|
||||
import com.correx.core.inference.InferenceStatus
|
||||
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.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class DefaultRiskAssessorTest {
|
||||
|
||||
private val assessor = DefaultRiskAssessor()
|
||||
private val sessionId = SessionId("s1")
|
||||
private val stageId = StageId("stage-1")
|
||||
|
||||
private fun emptyReport() = ValidationReport(sections = emptyList())
|
||||
private fun emptyInferenceState() = InferenceState()
|
||||
private fun baseState() = OrchestrationState(currentStageId = stageId, status = OrchestrationStatus.RUNNING)
|
||||
|
||||
private fun ctx(
|
||||
report: ValidationReport = emptyReport(),
|
||||
state: OrchestrationState = baseState(),
|
||||
inferenceState: InferenceState = emptyInferenceState(),
|
||||
) = RiskContext(
|
||||
validationReport = report,
|
||||
orchestrationState = state,
|
||||
inferenceState = inferenceState,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `no signals yields LOW risk and PROCEED`() {
|
||||
val summary = assessor.assess(ctx())
|
||||
|
||||
assertEquals(RiskLevel.LOW, summary.level)
|
||||
assertEquals(RiskAction.PROCEED, summary.recommendedAction)
|
||||
assertTrue(summary.signals.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validation errors produce ValidationErrors signal at MEDIUM`() {
|
||||
val report = ValidationReport(
|
||||
sections = listOf(
|
||||
ValidationSection(
|
||||
name = "schema",
|
||||
issues = listOf(
|
||||
ValidationIssue(code = "MISSING_FIELD", message = "field required",
|
||||
severity = ValidationSeverity.ERROR),
|
||||
ValidationIssue(code = "MISSING_FIELD", message = "other field",
|
||||
severity = ValidationSeverity.ERROR),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val summary = assessor.assess(ctx(report = report))
|
||||
|
||||
assertEquals(RiskLevel.MEDIUM, summary.level)
|
||||
assertEquals(RiskAction.PROMPT_USER, summary.recommendedAction)
|
||||
val signal = summary.signals.filterIsInstance<RiskSignal.ValidationErrors>().single()
|
||||
assertEquals(2, signal.errorCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cycle issue in validation report produces CycleWithoutExit signal at MEDIUM`() {
|
||||
val report = ValidationReport(
|
||||
sections = listOf(
|
||||
ValidationSection(
|
||||
name = "semantic",
|
||||
issues = listOf(
|
||||
ValidationIssue(code = "CYCLE_WITHOUT_POLICY", message = "cycle detected",
|
||||
severity = ValidationSeverity.WARNING),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val summary = assessor.assess(ctx(report = report))
|
||||
|
||||
assertEquals(RiskLevel.MEDIUM, summary.level)
|
||||
val signal = summary.signals.filterIsInstance<RiskSignal.CycleWithoutExit>().single()
|
||||
assertEquals("CYCLE_WITHOUT_POLICY", signal.cycleId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retryCount at maxAttempts minus one produces RepeatedFailure signal at HIGH`() {
|
||||
val state = OrchestrationState(
|
||||
currentStageId = stageId,
|
||||
status = OrchestrationStatus.RUNNING,
|
||||
retryCount = 2,
|
||||
failureReason = "inference failed",
|
||||
retryPolicy = RetryPolicy(maxAttempts = 3),
|
||||
)
|
||||
|
||||
val summary = assessor.assess(ctx(state = state))
|
||||
|
||||
assertEquals(RiskLevel.HIGH, summary.level)
|
||||
assertEquals(RiskAction.PROMPT_USER, summary.recommendedAction)
|
||||
val signal = summary.signals.filterIsInstance<RiskSignal.RepeatedFailure>().single()
|
||||
assertEquals(2, signal.count)
|
||||
assertEquals("inference failed", signal.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no retry policy skips RepeatedFailure signal even at high retryCount`() {
|
||||
val state = OrchestrationState(
|
||||
currentStageId = stageId,
|
||||
status = OrchestrationStatus.RUNNING,
|
||||
retryCount = 99,
|
||||
retryPolicy = null,
|
||||
)
|
||||
|
||||
val summary = assessor.assess(ctx(state = state))
|
||||
|
||||
assertEquals(RiskLevel.LOW, summary.level)
|
||||
assertTrue(summary.signals.filterIsInstance<RiskSignal.RepeatedFailure>().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `timed-out inference record for current stage produces InferenceTimeout signal at MEDIUM`() {
|
||||
val inferenceState = InferenceState(
|
||||
records = listOf(
|
||||
InferenceRecord(
|
||||
requestId = InferenceRequestId("r1"),
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
providerId = ProviderId("p1"),
|
||||
status = InferenceStatus.TIMED_OUT,
|
||||
latencyMs = 30_000L,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val summary = assessor.assess(ctx(inferenceState = inferenceState))
|
||||
|
||||
assertEquals(RiskLevel.MEDIUM, summary.level)
|
||||
val signal = summary.signals.filterIsInstance<RiskSignal.InferenceTimeout>().single()
|
||||
assertEquals(30_000L, signal.elapsedMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `timed-out inference for different stage does not produce signal`() {
|
||||
val inferenceState = InferenceState(
|
||||
records = listOf(
|
||||
InferenceRecord(
|
||||
requestId = InferenceRequestId("r1"),
|
||||
sessionId = sessionId,
|
||||
stageId = StageId("other-stage"),
|
||||
providerId = ProviderId("p1"),
|
||||
status = InferenceStatus.TIMED_OUT,
|
||||
latencyMs = 30_000L,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val summary = assessor.assess(ctx(inferenceState = inferenceState))
|
||||
|
||||
assertEquals(RiskLevel.LOW, summary.level)
|
||||
assertTrue(summary.signals.filterIsInstance<RiskSignal.InferenceTimeout>().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `multiple signals fold to highest risk level`() {
|
||||
val report = ValidationReport(
|
||||
sections = listOf(
|
||||
ValidationSection(
|
||||
name = "schema",
|
||||
issues = listOf(
|
||||
ValidationIssue(code = "MISSING_FIELD", message = "required",
|
||||
severity = ValidationSeverity.ERROR),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
val state = OrchestrationState(
|
||||
currentStageId = stageId,
|
||||
status = OrchestrationStatus.RUNNING,
|
||||
retryCount = 2,
|
||||
failureReason = "timeout",
|
||||
retryPolicy = RetryPolicy(maxAttempts = 3),
|
||||
)
|
||||
|
||||
val summary = assessor.assess(ctx(report = report, state = state))
|
||||
|
||||
assertEquals(RiskLevel.HIGH, summary.level)
|
||||
assertEquals(2, summary.signals.size)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user