Evaluando Reglas: Recorrido por el Motor
Ordena una vez, evalúa cada regla en orden de prioridad, deja que una parada dura gane de inmediato y luego limita los boosts — cuatro pasos pequeños convierten un montón de reglas configuradas en un precio defendible.
Todo lo anterior ha estado construyendo hacia esto: un vocabulario `Rule` de hace dos lecciones, y una forma de cargar instancias configuradas de él desde CSV o Postgres de la última. Esta lección escribe la última pieza — el `RulesEngine` pequeño y estable que en realidad ejecuta las reglas configuradas contra un vehículo y produce una `PricingDecision`. Esta es la pieza del sistema que casi nunca debería cambiar, incluso cuando las reglas debajo de ella cambien cada semana; si te encuentras editando `RulesEngine` para soportar un nuevo escenario de negocio, normalmente es una señal de que ese escenario debería ser una nueva implementación de `Rule` en vez de un cambio al motor.
El motor ordena sus reglas por prioridad exactamente una vez, al construirse — no en cada solicitud de valuación que llega. Ordenar unas pocas docenas de reglas es barato en aislamiento, pero se espera que este servicio responda miles de solicitudes de valuación por minuto (Datadog te mostrará el número real una vez que el Módulo 6 conecte las métricas), y reordenar la misma lista fija en cada una de esas solicitudes es puro desperdicio. La evaluación en sí tampoco se detiene en la primera coincidencia: corre cada regla contra el contexto y recolecta cada resultado no nulo con `mapNotNull`, porque un solo vehículo puede legítimamente disparar más de un boost — poco kilometraje y un año de modelo reciente son hechos completamente independientes, y ambos deberían contar.
Una vez que se recolectan todos los resultados, el motor busca primero una parada dura: el primer `Guardrail` o `NoBuy` en la lista, sin importar qué más se haya disparado. Si existe uno, el vehículo se rechaza de inmediato, sin excepción — ningún boost que también se haya disparado puede suavizar ese resultado, y ningún `NoOffer` puede escalarlo, porque una parada dura es una señal más fuerte y más definitiva que cualquiera de los dos. Esta es precisamente la garantía de orden que la prioridad existe para darte: como los guardrails y los no-buy se ubican al frente de la lista ordenada, un vehículo muy dañado dispara la comprobación de `NoBuy` temprano, pero el corte real ocurre al escanear los resultados *ya recolectados* en busca de una parada dura antes de hacer cualquier otra cosa con los boosts — la prioridad controla el orden en que corren las reglas, y esta comprobación de parada dura controla qué pasa una vez que ya corrieron.
Si nada detuvo al vehículo de forma dura, el motor comprueba a continuación si hay un `NoOffer`. Este es el resultado que mantiene un vehículo en juego pero se niega a dejar que un algoritmo fije su precio — un conteo de accidentes elevado pero no descalificante es el ejemplo canónico. El resultado es un `PricingDecision.Escalated`, que en un sistema real caería en la cola de un suscriptor humano en vez de convertirse en una oferta automatizada. Fíjate que esta comprobación corre antes de sumar los boosts, por la misma razón que la parada dura: no tiene sentido calcular un precio que un humano no va a terminar usando.
Solo una vez que un vehículo pasa ambas comprobaciones el motor realmente lo tasa: suma el monto de cada resultado `Boost`, acota el total con `coerceAtMost(maxBoostAmount)`, y suma lo que sobreviva al recorte a la valuación cruda del proveedor. El límite no es decoración — es una medida de defensa en profundidad contra una tabla `pricing_rule` que alguien (o algún script de importación) configuró mal. Un solo boost con un monto mal escrito de `50000` en vez de `500` no debería poder convertir un error de captura de datos en un sobrepago de cinco cifras por un solo auto; el límite garantiza que el radio de daño de una fila mala en esa tabla está acotado sin importar cuán grande sea el número que contenga.
Sigue un vehículo a través de los datos de muestra de abajo para ver los cuatro pasos en acción: VIN-102 tiene 45,000 millas, es modelo 2019, con tres accidentes reportados. Sus resultados son exactamente uno — `HighAccidentNoOffer` se dispara porque tres accidentes caen en su rango de revisión de 2 a 4 — y nada más aplica (no es lo bastante viejo para el guardrail de edad ni está lo bastante dañado para el no-buy por pérdida total, y no tiene ni poco kilometraje ni es lo bastante nuevo para ninguno de los boosts). No existe parada dura, así que el motor pasa a la comprobación de `NoOffer`, encuentra una, y devuelve `Escalated` sin tocar jamás la aritmética de los boosts. Compara eso con VIN-100 — nuevo, con poco kilometraje, sin accidentes — que pasa todas las comprobaciones y termina en un `Offer` con sus dos boosts sumados y limitados a $1,500.
Fíjate en lo que este motor no ha necesitado ni una sola vez en toda esta lección: una conexión a base de datos, un cliente HTTP, o un mock de cualquiera de los dos. `RulesEngine.decide()` es Kotlin puro sobre una lista en memoria y una data class — lo que significa que ya es completamente comprobable con pruebas unitarias usando nada más que la stdlib que usaste para escribir el programa de muestra de abajo. Eso es deliberado, y prepara exactamente lo que viene después: la siguiente lección trae Testcontainers, porque las capas entre las que se sienta este motor — el repositorio que carga las filas de `pricing_rule`, y los proveedores de donde viene la valuación cruda — son exactamente las partes de este servicio que sí necesitan un Postgres real y un Mongo real para probarse honestamente.
// Continuing the Rule/RuleOutcome vocabulary from the previous lesson.// PricingDecision is what the engine hands back to the caller.sealed class PricingDecision {data class Offer(val amount: Double, val appliedBoosts: List<String>) : PricingDecision()data class Refused(val reason: String) : PricingDecision()data class Escalated(val reason: String) : PricingDecision()}class RulesEngine(rules: List<Rule>, private val maxBoostAmount: Double = 1_500.0) {// Sorted once, at construction time -- not on every valuation request.private val orderedRules = rules.sortedBy { it.priority }fun decide(context: ValuationContext): PricingDecision {val outcomes = orderedRules.mapNotNull { it.evaluate(context) }// Step 1: any hard stop -- Guardrail or NoBuy -- wins immediately.val hardStop = outcomes.firstOrNull { it is RuleOutcome.Guardrail || it is RuleOutcome.NoBuy }if (hardStop != null) {val reason = when (hardStop) {is RuleOutcome.Guardrail -> hardStop.reasonis RuleOutcome.NoBuy -> hardStop.reasonelse -> error("unreachable")}return PricingDecision.Refused(reason)}// Step 2: NoOffer defers to a human without refusing the vehicle outright.val noOffer = outcomes.filterIsInstance<RuleOutcome.NoOffer>().firstOrNull()if (noOffer != null) {return PricingDecision.Escalated(noOffer.reason)}// Step 3: boosts stack, but never past the cap.val boosts = outcomes.filterIsInstance<RuleOutcome.Boost>()val totalBoost = boosts.sumOf { it.amount }.coerceAtMost(maxBoostAmount)val finalAmount = context.providerValuation + totalBoostreturn PricingDecision.Offer(finalAmount, boosts.map { it.reason })}}
PricingDecision, the type the engine hands back to callers, and decide()'s four steps in isolation — continuing the Rule/RuleOutcome vocabulary from the previous lesson.
data class ValuationContext(val vin: String,val mileage: Int,val modelYear: Int,val accidentCount: Int,val providerValuation: Double,)sealed class RuleOutcome {data class Guardrail(val reason: String) : RuleOutcome()data class Boost(val amount: Double, val reason: String) : RuleOutcome()data class NoBuy(val reason: String) : RuleOutcome()data class NoOffer(val reason: String) : RuleOutcome()}interface Rule {val name: Stringval priority: Intfun evaluate(context: ValuationContext): RuleOutcome?}sealed class PricingDecision {data class Offer(val amount: Double, val appliedBoosts: List<String>) : PricingDecision()data class Refused(val reason: String) : PricingDecision()data class Escalated(val reason: String) : PricingDecision()}class RulesEngine(rules: List<Rule>, private val maxBoostAmount: Double = 1_500.0) {private val orderedRules = rules.sortedBy { it.priority }fun decide(context: ValuationContext): PricingDecision {val outcomes = orderedRules.mapNotNull { it.evaluate(context) }val hardStop = outcomes.firstOrNull { it is RuleOutcome.Guardrail || it is RuleOutcome.NoBuy }if (hardStop != null) {val reason = when (hardStop) {is RuleOutcome.Guardrail -> hardStop.reasonis RuleOutcome.NoBuy -> hardStop.reasonelse -> error("unreachable")}return PricingDecision.Refused(reason)}val noOffer = outcomes.filterIsInstance<RuleOutcome.NoOffer>().firstOrNull()if (noOffer != null) {return PricingDecision.Escalated(noOffer.reason)}val boosts = outcomes.filterIsInstance<RuleOutcome.Boost>()val totalBoost = boosts.sumOf { it.amount }.coerceAtMost(maxBoostAmount)val finalAmount = context.providerValuation + totalBoostreturn PricingDecision.Offer(finalAmount, boosts.map { it.reason })}}class MaxAgeGuardrail(override val priority: Int = 0) : Rule {override val name = "max-age-guardrail"override fun evaluate(context: ValuationContext): RuleOutcome? =if (context.modelYear < 2012) RuleOutcome.Guardrail("Model year ${context.modelYear} is too old to buy") else null}class TotalLossNoBuy(override val priority: Int = 1) : Rule {override val name = "total-loss-no-buy"override fun evaluate(context: ValuationContext): RuleOutcome? =if (context.accidentCount >= 5) RuleOutcome.NoBuy("Accident count ${context.accidentCount} indicates a total loss") else null}class HighAccidentNoOffer(override val priority: Int = 5) : Rule {override val name = "high-accident-no-offer"override fun evaluate(context: ValuationContext): RuleOutcome? =if (context.accidentCount in 2..4) RuleOutcome.NoOffer("Accident count ${context.accidentCount} requires manual review") else null}class LowMileageBoost(override val priority: Int = 10) : Rule {override val name = "low-mileage-boost"override fun evaluate(context: ValuationContext): RuleOutcome? =if (context.mileage < 30_000) RuleOutcome.Boost(500.0, "Mileage ${context.mileage} is under 30,000") else null}class RecentModelYearBoost(override val priority: Int = 11) : Rule {override val name = "recent-model-year-boost"override fun evaluate(context: ValuationContext): RuleOutcome? =if (context.modelYear >= 2023) RuleOutcome.Boost(1200.0, "Model year ${context.modelYear} is recent") else null}fun main() {val engine = RulesEngine(rules = listOf(LowMileageBoost(),MaxAgeGuardrail(),TotalLossNoBuy(),HighAccidentNoOffer(),RecentModelYearBoost(),),maxBoostAmount = 1_500.0,)val vehicles = listOf(ValuationContext("VIN-100", mileage = 12_000, modelYear = 2024, accidentCount = 0, providerValuation = 28_000.0),ValuationContext("VIN-101", mileage = 80_000, modelYear = 2010, accidentCount = 0, providerValuation = 5_000.0),ValuationContext("VIN-102", mileage = 45_000, modelYear = 2019, accidentCount = 3, providerValuation = 14_000.0),ValuationContext("VIN-103", mileage = 60_000, modelYear = 2018, accidentCount = 6, providerValuation = 9_000.0),)for (vehicle in vehicles) {println("${vehicle.vin} -> ${engine.decide(vehicle)}")}}
The complete engine, five configured rules, and a trace over four vehicles — run it and match the printed decisions against the walkthrough above.
Arena IDE🧠 Comprueba tu comprensión
0/1 · 0/1 answered1. A vehicle in the same evaluation pass triggers a `NoBuy` outcome (from a total-loss rule) and a `Boost` outcome (from a low-mileage rule). What should `RulesEngine.decide()` return, and why?