From ee3f9b8aaab91a467b2b86d8949d6fda074fe2ad Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 30 May 2026 13:06:39 +0400 Subject: [PATCH] feat: carry validation rationale through risk assessment Risk tier is now derived from the worst validation issue severity and the issues that drove it travel with the summary, so consumers can show operators why a tier was assigned instead of an opaque level. - Add RiskSummary.rationale (defaulted, backward-compatible) holding human-readable '[code] message' lines from the validation report - Pin severity -> risk mapping: ERROR->HIGH, WARNING->MEDIUM, INFO->LOW - DefaultRiskAssessor folds the validation-driven level into the summary --- .../correx/core/events/risk/RiskSummary.kt | 1 + .../correx/core/risk/DefaultRiskAssessor.kt | 22 ++++++-- .../com/correx/core/risk/NoOpRiskAssessor.kt | 1 + .../com/correx/core/risk/TierMapping.kt | 2 +- .../core/risk/DefaultRiskAssessorTest.kt | 56 ++++++++++++++++++- 5 files changed, 73 insertions(+), 9 deletions(-) diff --git a/core/events/src/main/kotlin/com/correx/core/events/risk/RiskSummary.kt b/core/events/src/main/kotlin/com/correx/core/events/risk/RiskSummary.kt index 038e45ad..2af5e826 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/risk/RiskSummary.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/risk/RiskSummary.kt @@ -4,4 +4,5 @@ data class RiskSummary( val level: RiskLevel, val signals: List, val recommendedAction: RiskAction, + val rationale: List = emptyList(), ) diff --git a/core/risk/src/main/kotlin/com/correx/core/risk/DefaultRiskAssessor.kt b/core/risk/src/main/kotlin/com/correx/core/risk/DefaultRiskAssessor.kt index 02aea6cc..b3632548 100644 --- a/core/risk/src/main/kotlin/com/correx/core/risk/DefaultRiskAssessor.kt +++ b/core/risk/src/main/kotlin/com/correx/core/risk/DefaultRiskAssessor.kt @@ -11,15 +11,14 @@ class DefaultRiskAssessor : RiskAssessor { val (report, state, inferenceState) = context val signals = mutableListOf() - val errorCount = report.sections.sumOf { s -> - s.issues.count { it.severity == ValidationSeverity.ERROR } - } + val allIssues = report.sections.flatMap { it.issues } + + val errorCount = allIssues.count { it.severity == ValidationSeverity.ERROR } if (errorCount > 0) { signals += RiskSignal.ValidationErrors(errorCount) } - report.sections.flatMap { it.issues } - .firstOrNull { it.code.contains("CYCLE", ignoreCase = true) } + allIssues.firstOrNull { it.code.contains("CYCLE", ignoreCase = true) } ?.let { signals += RiskSignal.CycleWithoutExit(it.code) } state.retryPolicy?.let { policy -> @@ -37,12 +36,23 @@ class DefaultRiskAssessor : RiskAssessor { ?.let { signals += RiskSignal.InferenceTimeout(it.latencyMs ?: 0L) } } - val level = signals.fold(RiskLevel.LOW) { acc, signal -> maxOf(acc, signal.toRiskLevel()) } + val validationDrivenLevel = when { + allIssues.any { it.severity == ValidationSeverity.ERROR } -> RiskLevel.HIGH + allIssues.any { it.severity == ValidationSeverity.WARNING } -> RiskLevel.MEDIUM + allIssues.any { it.severity == ValidationSeverity.INFO } -> RiskLevel.LOW + else -> RiskLevel.LOW + } + + var level = signals.fold(RiskLevel.LOW) { acc, signal -> maxOf(acc, signal.toRiskLevel()) } + level = maxOf(level, validationDrivenLevel) + + val rationale = allIssues.map { "[${it.code}] ${it.message}" } return RiskSummary( level = level, signals = signals, recommendedAction = level.toRiskAction(), + rationale = rationale, ) } } diff --git a/core/risk/src/main/kotlin/com/correx/core/risk/NoOpRiskAssessor.kt b/core/risk/src/main/kotlin/com/correx/core/risk/NoOpRiskAssessor.kt index 9fb0f911..0457b356 100644 --- a/core/risk/src/main/kotlin/com/correx/core/risk/NoOpRiskAssessor.kt +++ b/core/risk/src/main/kotlin/com/correx/core/risk/NoOpRiskAssessor.kt @@ -14,5 +14,6 @@ class NoOpRiskAssessor : RiskAssessor { level = RiskLevel.LOW, signals = emptyList(), recommendedAction = RiskAction.PROCEED, + rationale = emptyList(), ) } diff --git a/core/risk/src/main/kotlin/com/correx/core/risk/TierMapping.kt b/core/risk/src/main/kotlin/com/correx/core/risk/TierMapping.kt index cb42fbdb..080c7460 100644 --- a/core/risk/src/main/kotlin/com/correx/core/risk/TierMapping.kt +++ b/core/risk/src/main/kotlin/com/correx/core/risk/TierMapping.kt @@ -20,7 +20,7 @@ internal fun RiskLevel.toRiskAction(): RiskAction = when (this) { } internal fun RiskSignal.toRiskLevel(): RiskLevel = when (this) { - is RiskSignal.ValidationErrors -> RiskLevel.MEDIUM + is RiskSignal.ValidationErrors -> RiskLevel.HIGH is RiskSignal.CycleWithoutExit -> RiskLevel.MEDIUM is RiskSignal.InferenceTimeout -> RiskLevel.MEDIUM is RiskSignal.RepeatedFailure -> RiskLevel.HIGH diff --git a/core/risk/src/test/kotlin/com/correx/core/risk/DefaultRiskAssessorTest.kt b/core/risk/src/test/kotlin/com/correx/core/risk/DefaultRiskAssessorTest.kt index 57d0c51f..9121aefe 100644 --- a/core/risk/src/test/kotlin/com/correx/core/risk/DefaultRiskAssessorTest.kt +++ b/core/risk/src/test/kotlin/com/correx/core/risk/DefaultRiskAssessorTest.kt @@ -51,7 +51,7 @@ class DefaultRiskAssessorTest { } @Test - fun `validation errors produce ValidationErrors signal at MEDIUM`() { + fun `validation errors produce ValidationErrors signal at HIGH`() { val report = ValidationReport( sections = listOf( ValidationSection( @@ -68,12 +68,32 @@ class DefaultRiskAssessorTest { val summary = assessor.assess(ctx(report = report)) - assertEquals(RiskLevel.MEDIUM, summary.level) + assertEquals(RiskLevel.HIGH, summary.level) assertEquals(RiskAction.PROMPT_USER, summary.recommendedAction) val signal = summary.signals.filterIsInstance().single() assertEquals(2, signal.errorCount) } + @Test + fun `validation warnings produce MEDIUM risk when no errors present`() { + val report = ValidationReport( + sections = listOf( + ValidationSection( + name = "schema", + issues = listOf( + ValidationIssue(code = "MISSING_CONTEXT", message = "context incomplete", + severity = ValidationSeverity.WARNING), + ), + ), + ), + ) + + val summary = assessor.assess(ctx(report = report)) + + assertEquals(RiskLevel.MEDIUM, summary.level) + assertEquals(RiskAction.PROMPT_USER, summary.recommendedAction) + } + @Test fun `cycle issue in validation report produces CycleWithoutExit signal at MEDIUM`() { val report = ValidationReport( @@ -172,6 +192,38 @@ class DefaultRiskAssessorTest { assertTrue(summary.signals.filterIsInstance().isEmpty()) } + @Test + fun `rationale contains formatted issue codes and messages`() { + val report = ValidationReport( + sections = listOf( + ValidationSection( + name = "schema", + issues = listOf( + ValidationIssue(code = "MISSING_FIELD", message = "field required", + severity = ValidationSeverity.ERROR), + ValidationIssue(code = "INVALID_TYPE", message = "type mismatch", + severity = ValidationSeverity.WARNING), + ), + ), + ), + ) + + val summary = assessor.assess(ctx(report = report)) + + assertTrue(summary.rationale.contains("[MISSING_FIELD] field required")) + assertTrue(summary.rationale.contains("[INVALID_TYPE] type mismatch")) + assertEquals(2, summary.rationale.size) + } + + @Test + fun `empty validation report yields empty rationale`() { + val report = ValidationReport(sections = emptyList()) + + val summary = assessor.assess(ctx(report = report)) + + assertTrue(summary.rationale.isEmpty()) + } + @Test fun `multiple signals fold to highest risk level`() { val report = ValidationReport(