feat(critique): structured CritiqueFinding + per-model calibration projection

- CritiqueFinding/CritiqueSeverity/CritiqueRole shared type (BACKLOG B6)
- CritiqueOutcomeCorrelatedEvent + serialization registration (BACKLOG C-A3)
- core/critique module: CriticCalibration state/reducer/projector, keyed by (modelHash, role)
- schema+projection only; live reviewer-loop producer wiring deferred

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 07:45:23 +00:00
parent b1c0f88dee
commit f783eed4be
11 changed files with 346 additions and 0 deletions
@@ -0,0 +1,33 @@
package com.correx.core.events.events
import com.correx.core.events.types.SessionId
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* How a prior [CritiqueFinding] actually turned out once the surrounding loop resolved.
* UPHELD = the finding was confirmed/actioned; DISMISSED = rejected as a false positive;
* INCONCLUSIVE = no signal either way.
*/
@Serializable
enum class CritiqueOutcome { UPHELD, DISMISSED, INCONCLUSIVE }
/**
* Correlates one prior [CritiqueFinding] (by [findingId]) with how it actually turned out, so a
* critic's precision can be tracked per model and per role over time (the calibration projection
* in `:core:critique`). [modelHash] identifies the model that produced the finding; [severity]
* is carried so calibration can be sliced by severity band without re-reading the finding.
*
* This is the recording half only — who decides UPHELD/DISMISSED lives in the reviewer-loop
* runtime and is wired separately.
*/
@Serializable
@SerialName("CritiqueOutcomeCorrelated")
data class CritiqueOutcomeCorrelatedEvent(
val sessionId: SessionId,
val findingId: String,
val role: CritiqueRole,
val modelHash: String,
val severity: CritiqueSeverity,
val outcome: CritiqueOutcome,
) : EventPayload
@@ -0,0 +1,34 @@
package com.correx.core.events.events
import kotlinx.serialization.Serializable
/** Which critic role produced a [CritiqueFinding]. */
@Serializable
enum class CritiqueRole { PLAN_CRITIC, CODE_REVIEWER }
/** Severity of a [CritiqueFinding], highest-impact first. */
@Serializable
enum class CritiqueSeverity { BLOCKER, MAJOR, MINOR, NIT }
/**
* One structured finding emitted by a critic (plan critic) or reviewer (code reviewer),
* replacing the free-text verdict prose those roles produced before. Shared by both roles so
* findings can be counted, ranked, and calibrated uniformly.
*
* [id] is a stable identifier for the finding, used to correlate it with its eventual outcome
* (see [CritiqueOutcomeCorrelatedEvent]). [category] is a free-form classification
* (e.g. "correctness", "missing_requirement", "style"). [location] optionally points at a
* `file:line` or an artifact/stage reference the finding concerns.
*
* Distinct from `core.validation.ValidationIssue`, which models *structural* validation
* (graph/schema) rather than a critic's judgement.
*/
@Serializable
data class CritiqueFinding(
val id: String,
val role: CritiqueRole,
val severity: CritiqueSeverity,
val category: String,
val message: String,
val location: String? = null,
)
@@ -13,6 +13,7 @@ import com.correx.core.events.events.ChatSessionStartedEvent
import com.correx.core.events.events.ClarificationAnsweredEvent
import com.correx.core.events.events.ClarificationRequestedEvent
import com.correx.core.events.events.ChatTurnEvent
import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent
import com.correx.core.events.events.RouterNarrationEvent
import com.correx.core.events.events.OperatorProfileBoundEvent
import com.correx.core.events.events.ProjectProfileBoundEvent
@@ -127,6 +128,7 @@ val eventModule = SerializersModule {
subclass(WorkflowProposedEvent::class)
subclass(IdeaCapturedEvent::class)
subclass(IdeaDiscardedEvent::class)
subclass(CritiqueOutcomeCorrelatedEvent::class)
}
}
@@ -0,0 +1,33 @@
package com.correx.core.events.serialization
import com.correx.core.events.events.CritiqueOutcome
import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent
import com.correx.core.events.events.CritiqueRole
import com.correx.core.events.events.CritiqueSeverity
import com.correx.core.events.events.EventPayload
import com.correx.core.events.types.SessionId
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class CritiqueCalibrationEventSerializationTest {
@Test
fun `CritiqueOutcomeCorrelatedEvent round-trips as polymorphic EventPayload`() {
val sample: EventPayload = CritiqueOutcomeCorrelatedEvent(
sessionId = SessionId("s"),
findingId = "f-1",
role = CritiqueRole.CODE_REVIEWER,
modelHash = "sha256:abc",
severity = CritiqueSeverity.MAJOR,
outcome = CritiqueOutcome.UPHELD,
)
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
assertTrue(
encoded.contains("\"type\":\"CritiqueOutcomeCorrelated\""),
"SerialName must be present: $encoded",
)
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
assertEquals(sample, decoded)
}
}